1use std::time::Duration;
9
10use base64::Engine as _;
11use serde_json::{Map, Value, json};
12use yapi_types::message::{
13 AssistantMessage, Content, ContentBlock, ImageContent, Message, StopReason, ThinkingLevel,
14 ToolCall, ToolDeclaration, ToolResultMessage,
15};
16use yapi_types::model::Model;
17
18use crate::aws::{self, AwsEnv, Credentials, eventstream, sigv4};
19use crate::cost::calculate_cost;
20use crate::credentials::provider_env_value;
21use crate::http::{self, Headers};
22use crate::json_parse::parse_streaming_json;
23use crate::schema;
24use crate::stream::{
25 CacheRetention, EventSender, Request, StreamEvent, StreamOptions, check_complete, new_output,
26 now_ms, send_error,
27};
28use crate::thinking::{budgeted, requested_max_tokens};
29use crate::transcript::{
30 current_tools, initial_system_message, resolve_transcript, transform_messages,
31};
32
33const EMPTY_TEXT: &str = "<empty>";
34const REDACTED_THINKING: &str = "[Reasoning redacted]";
35const DATA_RETENTION_DOCS: &str =
36 "https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html";
37
38fn match_candidates(model: &Model) -> Vec<String> {
40 let mut values = vec![model.id.to_lowercase()];
41 if !model.name.is_empty() {
42 values.push(model.name.to_lowercase());
43 }
44 values
45 .into_iter()
46 .flat_map(|lower| {
47 let mut dashed = String::new();
48 let mut previous_separator = false;
49 for c in lower.chars() {
50 if c.is_whitespace() || matches!(c, '_' | '.' | ':') {
51 if !previous_separator {
52 dashed.push('-');
53 }
54 previous_separator = true;
55 } else {
56 dashed.push(c);
57 previous_separator = false;
58 }
59 }
60 [lower, dashed]
61 })
62 .collect()
63}
64
65fn is_claude(model: &Model) -> bool {
66 let id = model.id.to_lowercase();
67 let name = model.name.to_lowercase();
68 id.contains("anthropic.claude")
69 || id.contains("anthropic/claude")
70 || name.contains("anthropic.claude")
71 || name.contains("anthropic/claude")
72 || name.contains("claude")
73}
74
75fn any_candidate(model: &Model, needles: &[&str]) -> bool {
76 match_candidates(model)
77 .iter()
78 .any(|candidate| needles.iter().any(|needle| candidate.contains(needle)))
79}
80
81fn supports_adaptive_thinking(model: &Model) -> bool {
82 any_candidate(
83 model,
84 &[
85 "opus-4-6",
86 "opus-4-7",
87 "opus-4-8",
88 "opus-5",
89 "sonnet-4-6",
90 "sonnet-5",
91 "fable-5",
92 ],
93 )
94}
95
96fn supports_native_xhigh(model: &Model) -> bool {
97 any_candidate(
98 model,
99 &["opus-4-7", "opus-4-8", "opus-5", "sonnet-5", "fable-5"],
100 )
101}
102
103fn effort(model: &Model, level: ThinkingLevel) -> String {
104 if level == ThinkingLevel::Xhigh && supports_native_xhigh(model) {
105 return "xhigh".into();
106 }
107 if let Some(Some(mapped)) = model.thinking_level_value(level) {
108 return mapped.to_owned();
109 }
110 match level {
111 ThinkingLevel::Minimal | ThinkingLevel::Low => "low",
112 ThinkingLevel::Medium => "medium",
113 _ => "high",
114 }
115 .into()
116}
117
118fn supports_prompt_caching(model: &Model, env: &dyn Fn(&str) -> Option<String>) -> bool {
119 let candidates = match_candidates(model);
120 if !candidates.iter().any(|c| c.contains("claude")) {
121 return env("AWS_BEDROCK_FORCE_CACHE").as_deref() == Some("1");
122 }
123 candidates.iter().any(|c| {
124 c.contains("fable-5")
125 || c.contains("opus-5")
126 || c.contains("sonnet-5")
127 || c.contains("-4-")
128 || c.contains("claude-3-7-sonnet")
129 || c.contains("claude-3-5-haiku")
130 })
131}
132
133fn cache_point(retention: CacheRetention) -> Value {
134 let mut point = Map::new();
135 point.insert("type".into(), json!("default"));
136 if retention == CacheRetention::Long {
137 point.insert("ttl".into(), json!("1h"));
138 }
139 json!({ "cachePoint": point })
140}
141
142fn text_block(text: &str) -> Option<Value> {
143 (!text.trim().is_empty()).then(|| json!({ "text": text }))
144}
145
146fn required_text_block(text: &str) -> Value {
147 text_block(text).unwrap_or_else(|| json!({ "text": EMPTY_TEXT }))
148}
149
150fn normalize_base64(data: &str) -> Result<String, String> {
152 let engine = base64::engine::general_purpose::STANDARD;
153 let bytes = engine
154 .decode(data.trim())
155 .or_else(|_| {
156 base64::engine::general_purpose::STANDARD_NO_PAD
157 .decode(data.trim().trim_end_matches('='))
158 })
159 .map_err(|_| "The string to be decoded is not correctly encoded.".to_owned())?;
160 Ok(engine.encode(bytes))
161}
162
163fn image_block(image: &ImageContent) -> Result<Value, String> {
164 let format = match image.mime_type.as_str() {
165 "image/jpeg" | "image/jpg" => "jpeg",
166 "image/png" => "png",
167 "image/gif" => "gif",
168 "image/webp" => "webp",
169 other => return Err(format!("Unknown image type: {other}")),
170 };
171 Ok(json!({
172 "image": { "format": format, "source": { "bytes": normalize_base64(&image.data)? } }
173 }))
174}
175
176fn sanitize_document(value: &Value) -> Value {
178 match value {
179 Value::Array(items) => Value::Array(items.iter().map(sanitize_document).collect()),
180 Value::Object(fields) => Value::Object(
181 fields
182 .iter()
183 .filter(|(key, _)| !key.is_empty())
184 .map(|(key, value)| (key.clone(), sanitize_document(value)))
185 .collect(),
186 ),
187 other => other.clone(),
188 }
189}
190
191fn tool_result_content(content: &[ContentBlock]) -> Result<Vec<Value>, String> {
192 let mut blocks = Vec::new();
193 for block in content {
194 match block {
195 ContentBlock::Image(image) => blocks.push(image_block(image)?),
196 ContentBlock::Text(text) => blocks.extend(text_block(&text.text)),
197 _ => {}
198 }
199 }
200 if blocks.is_empty() {
201 blocks.push(json!({ "text": EMPTY_TEXT }));
202 }
203 Ok(blocks)
204}
205
206fn tool_result(result: &ToolResultMessage) -> Result<Value, String> {
207 Ok(json!({
208 "toolResult": {
209 "toolUseId": result.tool_call_id,
210 "content": tool_result_content(&result.content)?,
211 "status": if result.is_error { "error" } else { "success" },
212 }
213 }))
214}
215
216fn assistant_blocks(assistant: &AssistantMessage, model: &Model) -> Vec<Value> {
217 let mut blocks = Vec::new();
218 for block in &assistant.content {
219 match block {
220 ContentBlock::Text(text) => blocks.extend(text_block(&text.text)),
221 ContentBlock::ToolCall(call) => blocks.push(json!({
222 "toolUse": {
223 "toolUseId": call.id,
224 "name": call.name,
225 "input": sanitize_document(&Value::Object(call.arguments.clone())),
226 }
227 })),
228 ContentBlock::Thinking(thinking) => {
229 if thinking.redacted == Some(true) {
230 if let Some(payload) = thinking
233 .thinking_signature
234 .as_deref()
235 .filter(|s| !s.is_empty())
236 .and_then(|s| normalize_base64(s).ok())
237 .filter(|s| !s.is_empty())
238 {
239 blocks.push(json!({ "reasoningContent": { "redactedContent": payload } }));
240 }
241 continue;
242 }
243 if thinking.thinking.trim().is_empty() {
244 continue;
245 }
246 if is_claude(model) {
247 match thinking
248 .thinking_signature
249 .as_deref()
250 .filter(|s| !s.trim().is_empty())
251 {
252 Some(signature) => blocks.push(json!({
253 "reasoningContent": {
254 "reasoningText": { "text": thinking.thinking, "signature": signature }
255 }
256 })),
257 None => blocks.push(json!({ "text": thinking.thinking })),
258 }
259 } else {
260 blocks.push(json!({
261 "reasoningContent": { "reasoningText": { "text": thinking.thinking } }
262 }));
263 }
264 }
265 ContentBlock::Image(_) => {}
266 }
267 }
268 blocks
269}
270
271fn convert_messages(
272 messages: &[Message],
273 model: &Model,
274 retention: CacheRetention,
275 caching: bool,
276) -> Result<Vec<Value>, String> {
277 let without_system: Vec<Message> = match messages.first() {
278 Some(Message::System(_)) => messages[1..].to_vec(),
279 _ => messages.to_vec(),
280 };
281 let normalize = |id: &str, _: &AssistantMessage| super::normalize_tool_call_id(id);
282 let transformed = transform_messages(&without_system, model, Some(&normalize), now_ms());
283 let mut result = Vec::new();
284 let mut index = 0;
285 while index < transformed.len() {
286 match &transformed[index] {
287 Message::User(user) => {
288 let content = match &user.content {
289 Content::Text(text) => vec![required_text_block(text)],
290 Content::Blocks(blocks) => {
291 let mut content = Vec::new();
292 for block in blocks {
293 match block {
294 ContentBlock::Text(text) => content.extend(text_block(&text.text)),
295 ContentBlock::Image(image) => content.push(image_block(image)?),
296 _ => {}
297 }
298 }
299 if content.is_empty() {
300 content.push(json!({ "text": EMPTY_TEXT }));
301 }
302 content
303 }
304 };
305 result.push(json!({ "role": "user", "content": content }));
306 }
307 Message::Assistant(assistant) => {
308 let blocks = assistant_blocks(assistant, model);
309 if !blocks.is_empty() {
310 result.push(json!({ "role": "assistant", "content": blocks }));
311 }
312 }
313 Message::ToolResult(first) => {
314 let mut results = vec![tool_result(first)?];
316 while let Some(Message::ToolResult(next)) = transformed.get(index + 1) {
317 results.push(tool_result(next)?);
318 index += 1;
319 }
320 result.push(json!({ "role": "user", "content": results }));
321 }
322 _ => {}
323 }
324 index += 1;
325 }
326 if retention != CacheRetention::None
327 && caching
328 && let Some(last) = result.last_mut()
329 && last["role"] == "user"
330 && let Some(content) = last["content"].as_array_mut()
331 {
332 content.push(cache_point(retention));
333 }
334 Ok(result)
335}
336
337fn tool_config(tools: &[ToolDeclaration], supports_strict: bool) -> Result<Option<Value>, String> {
338 if tools.is_empty() {
339 return Ok(None);
340 }
341 let mut specs = Vec::new();
342 for tool in tools {
343 let strict = schema::strict_sampling(tool, supports_strict, None)?;
344 let mut spec = Map::new();
345 spec.insert("name".into(), json!(tool.name));
346 spec.insert(
347 "inputSchema".into(),
348 json!({ "json": schema::tool_parameters(tool, strict) }),
349 );
350 spec.insert("description".into(), json!(tool.description));
351 if strict {
352 spec.insert("strict".into(), json!(true));
353 }
354 specs.push(json!({ "toolSpec": spec }));
355 }
356 Ok(Some(json!({ "tools": specs })))
357}
358
359struct Thinking {
362 level: Option<ThinkingLevel>,
363 budget: Option<u64>,
364 max_tokens: u64,
365}
366
367fn thinking(model: &Model, messages: &[Message], options: &StreamOptions) -> Thinking {
368 let base = requested_max_tokens(model, messages, options);
369 let level = options
370 .reasoning
371 .filter(|level| *level != ThinkingLevel::Off);
372 let Some(level) = level else {
373 return Thinking {
374 level: None,
375 budget: None,
376 max_tokens: base,
377 };
378 };
379 if is_claude(model) && !supports_adaptive_thinking(model) {
380 let (max_tokens, budget) =
381 budgeted(model, messages, base, level, &options.thinking_budgets);
382 return Thinking {
383 level: Some(level),
384 budget: Some(budget),
385 max_tokens,
386 };
387 }
388 Thinking {
389 level: Some(level),
390 budget: None,
391 max_tokens: base,
392 }
393}
394
395fn is_gov_cloud(model: &Model, region: Option<&str>) -> bool {
396 if region.is_some_and(|region| region.to_lowercase().starts_with("us-gov-")) {
397 return true;
398 }
399 let id = model.id.to_lowercase();
400 id.starts_with("us-gov.") || id.starts_with("arn:aws-us-gov:")
401}
402
403fn additional_fields(
404 model: &Model,
405 thinking: &Thinking,
406 options: &StreamOptions,
407 configured_region: Option<&str>,
408) -> Option<Value> {
409 let level = thinking.level?;
410 if !model.reasoning || !is_claude(model) {
411 return None;
412 }
413 let display = (!is_gov_cloud(model, configured_region)).then_some("summarized");
414 let mut config = Map::new();
415 config.insert(
416 "type".into(),
417 json!(if supports_adaptive_thinking(model) {
418 "adaptive"
419 } else {
420 "enabled"
421 }),
422 );
423 let mut fields = Map::new();
424 if supports_adaptive_thinking(model) {
425 if let Some(display) = display {
426 config.insert("display".into(), json!(display));
427 }
428 fields.insert("thinking".into(), Value::Object(config));
429 fields.insert(
430 "output_config".into(),
431 json!({ "effort": effort(model, level) }),
432 );
433 } else {
434 let budget = thinking
435 .budget
436 .unwrap_or_else(|| crate::thinking::budget_for_level(level, &options.thinking_budgets));
437 config.insert("budget_tokens".into(), json!(budget));
438 if let Some(display) = display {
439 config.insert("display".into(), json!(display));
440 }
441 fields.insert("thinking".into(), Value::Object(config));
442 fields.insert(
443 "anthropic_beta".into(),
444 json!(["interleaved-thinking-2025-05-14"]),
445 );
446 }
447 Some(Value::Object(fields))
448}
449
450fn build_body(
453 model: &Model,
454 messages: &[Message],
455 options: &StreamOptions,
456 thinking: &Thinking,
457 configured_region: Option<&str>,
458) -> Result<Value, String> {
459 let env = |name: &str| provider_env_value(name, options.env.as_ref());
460 let normalized = resolve_transcript(messages, false);
461 let retention = options.resolved_cache_retention();
462 let caching = supports_prompt_caching(model, &env);
463 let mut body = Map::new();
464 body.insert("modelId".into(), json!(model.id));
465 body.insert(
466 "messages".into(),
467 Value::Array(convert_messages(&normalized, model, retention, caching)?),
468 );
469 let system = initial_system_message(&normalized)
470 .map(|system| system.text())
471 .unwrap_or_default();
472 if !system.is_empty() {
473 let mut blocks = vec![json!({ "text": system })];
474 if retention != CacheRetention::None && caching {
475 blocks.push(cache_point(retention));
476 }
477 body.insert("system".into(), Value::Array(blocks));
478 }
479 let mut inference = Map::new();
480 inference.insert("maxTokens".into(), json!(thinking.max_tokens));
481 if let Some(temperature) = options.temperature {
482 inference.insert("temperature".into(), json!(temperature));
483 }
484 body.insert("inferenceConfig".into(), Value::Object(inference));
485 let supports_strict = model
486 .compat
487 .as_ref()
488 .and_then(|compat| compat.get("supportsStrictMode"))
489 .and_then(Value::as_bool)
490 .unwrap_or(false);
491 if let Some(config) = tool_config(¤t_tools(&normalized), supports_strict)? {
492 body.insert("toolConfig".into(), config);
493 }
494 if let Some(fields) = additional_fields(model, thinking, options, configured_region) {
495 body.insert("additionalModelRequestFields".into(), fields);
496 }
497 Ok(Value::Object(body))
498}
499
500fn endpoint_region(base_url: &str) -> Option<String> {
502 let url = url::Url::parse(base_url).ok()?;
503 let host = url.host_str()?.to_lowercase();
504 let rest = host
505 .strip_prefix("bedrock-runtime-fips.")
506 .or_else(|| host.strip_prefix("bedrock-runtime."))?;
507 let region = rest
508 .strip_suffix(".amazonaws.com.cn")
509 .or_else(|| rest.strip_suffix(".amazonaws.com"))?;
510 (!region.is_empty()
511 && region
512 .chars()
513 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
514 .then(|| region.to_owned())
515}
516
517fn arn_region(id: &str) -> Option<String> {
519 let rest = id.strip_prefix("arn:aws")?;
520 let rest = match rest.strip_prefix('-') {
521 Some(partition) => partition.split_once(':')?.1,
522 None => rest.strip_prefix(':')?,
523 };
524 let region = rest.strip_prefix("bedrock:")?.split(':').next()?;
525 (!region.is_empty()
526 && region
527 .chars()
528 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'))
529 .then(|| region.to_owned())
530}
531
532enum Signer {
534 Bearer(String),
535 SigV4(Credentials),
536}
537
538struct Target {
540 url: String,
541 region: String,
542 signer: Signer,
543}
544
545async fn target(model: &Model, model_id: &str, options: &StreamOptions) -> Result<Target, String> {
548 let env = |name: &str| provider_env_value(name, options.env.as_ref());
549 let process_profile = std::env::var("AWS_PROFILE")
550 .ok()
551 .filter(|profile| !profile.is_empty());
552 let options_profile = options
553 .env
554 .as_ref()
555 .and_then(|env| env.get("AWS_PROFILE"))
556 .filter(|profile| !profile.is_empty())
557 .cloned();
558 let configured_region = env("AWS_REGION").or_else(|| env("AWS_DEFAULT_REGION"));
559 let endpoint_region = endpoint_region(&model.base_url);
560 let explicit =
561 endpoint_region.is_none() || (configured_region.is_none() && process_profile.is_none());
562 let profile = options_profile.clone().or_else(|| env("AWS_PROFILE"));
563 let aws_env = AwsEnv::from_process(options.env.as_ref());
564 let region = if let Some(region) = arn_region(&model.id) {
565 region
566 } else if let Some(region) = configured_region {
567 region
568 } else if let (Some(region), true) = (endpoint_region, explicit) {
569 region
570 } else if process_profile.is_none() {
571 "us-east-1".into()
572 } else {
573 aws::profile_region(&aws_env, profile.as_deref())
574 .await
575 .ok_or_else(|| "Region is missing".to_owned())?
576 };
577 let skip_auth = env("AWS_BEDROCK_SKIP_AUTH").as_deref() == Some("1");
578 let bearer = options
579 .api_key
580 .clone()
581 .filter(|key| !key.is_empty())
582 .or_else(|| env("AWS_BEARER_TOKEN_BEDROCK"));
583 let signer = match bearer {
584 Some(token) if !skip_auth => Signer::Bearer(token),
585 _ if skip_auth => Signer::SigV4(Credentials {
586 access_key_id: "dummy-access-key".into(),
587 secret_access_key: "dummy-secret-key".into(),
588 session_token: None,
589 expiration_ms: None,
590 }),
591 _ => {
592 let static_keys = env("AWS_ACCESS_KEY_ID").zip(env("AWS_SECRET_ACCESS_KEY"));
593 match static_keys {
594 Some((id, secret)) if options_profile.is_none() => Signer::SigV4(Credentials {
595 access_key_id: id,
596 secret_access_key: secret,
597 session_token: env("AWS_SESSION_TOKEN"),
598 expiration_ms: None,
599 }),
600 _ => Signer::SigV4(
601 aws::default_credentials(
602 &aws_env,
603 profile.as_deref(),
604 Some(®ion),
605 &options.cancel,
606 )
607 .await?,
608 ),
609 }
610 }
611 };
612 let endpoint = if explicit {
613 model.base_url.clone()
614 } else if let Some(url) =
615 env("AWS_ENDPOINT_URL_BEDROCK_RUNTIME").or_else(|| env("AWS_ENDPOINT_URL"))
616 {
617 url
618 } else {
619 let fips = env("AWS_USE_FIPS_ENDPOINT").as_deref() == Some("true");
620 let host = if fips {
621 "bedrock-runtime-fips"
622 } else {
623 "bedrock-runtime"
624 };
625 let suffix = if region.starts_with("cn-") { ".cn" } else { "" };
626 format!("https://{host}.{region}.amazonaws.com{suffix}")
627 };
628 Ok(Target {
629 url: format!(
630 "{}/model/{}/converse-stream",
631 endpoint.trim_end_matches('/'),
632 sigv4::escape(model_id)
633 ),
634 region,
635 signer,
636 })
637}
638
639fn reserved(name: &str) -> bool {
641 let lower = name.to_lowercase();
642 lower.starts_with("x-amz-") || lower == "authorization" || lower == "host"
643}
644
645fn signed_headers(target: &Target, body: &str, options: &StreamOptions) -> Result<Headers, String> {
646 let url = url::Url::parse(&target.url).map_err(|err| err.to_string())?;
647 let mut headers = Headers::default();
648 headers.set("content-type", Some("application/json"));
649 for (name, value) in &options.headers {
650 if let Some(value) = value
651 && !reserved(name)
652 {
653 headers.set(name, Some(value.as_str()));
654 }
655 }
656 match &target.signer {
657 Signer::Bearer(token) => headers.set("authorization", Some(format!("Bearer {token}"))),
658 Signer::SigV4(credentials) => sigv4::sign_request(
659 &url,
660 &mut headers.0,
661 body.as_bytes(),
662 credentials,
663 &target.region,
664 "bedrock",
665 ),
666 }
667 Ok(headers)
668}
669
670struct Failure {
672 message: String,
673 status: Option<u16>,
674 code: Option<String>,
675 request_id: Option<String>,
676 retryable: bool,
677}
678
679impl Failure {
680 fn plain(message: String) -> Failure {
682 Failure {
683 message,
684 status: None,
685 code: None,
686 request_id: None,
687 retryable: false,
688 }
689 }
690}
691
692const ERROR_PREFIXES: &[(&str, &str)] = &[
693 ("InternalServerException", "Internal server error"),
694 ("ModelStreamErrorException", "Model stream error"),
695 ("ValidationException", "Validation error"),
696 ("ThrottlingException", "Throttling error"),
697 ("ServiceUnavailableException", "Service unavailable"),
698];
699
700const THROTTLING_CODES: &[&str] = &[
701 "BandwidthLimitExceeded",
702 "EC2ThrottledException",
703 "LimitExceededException",
704 "PriorRequestNotComplete",
705 "ProvisionedThroughputExceededException",
706 "RequestLimitExceeded",
707 "RequestThrottled",
708 "RequestThrottledException",
709 "SlowDown",
710 "ThrottledException",
711 "Throttling",
712 "ThrottlingException",
713 "TooManyRequestsException",
714 "TransactionInProgressException",
715];
716
717fn service_message(name: &str, message: &str) -> String {
719 let prefix = ERROR_PREFIXES
720 .iter()
721 .find(|(code, _)| *code == name)
722 .map_or(name, |(_, prefix)| prefix);
723 format!("{prefix}: {message}{}", retention_hint(message))
724}
725
726fn retention_hint(message: &str) -> String {
727 if message.to_lowercase().contains("data retention mode") {
728 format!(" See {DATA_RETENTION_DOCS} for supported data retention modes.")
729 } else {
730 String::new()
731 }
732}
733
734fn response_failure(status: u16, headers: &reqwest::header::HeaderMap, body: &str) -> Failure {
736 let header = |name: &str| {
737 headers
738 .get(name)
739 .and_then(|value| value.to_str().ok())
740 .map(str::to_owned)
741 };
742 let request_id = header("x-amzn-requestid").and_then(|id| diagnostic_value(&id));
743 let parsed = if body.is_empty() {
744 Ok(Value::Object(Map::new()))
745 } else {
746 serde_json::from_str::<Value>(body)
747 };
748 let Ok(json) = parsed else {
749 return Failure {
750 message: format!(
751 "{}\n Deserialization error: to see the raw response, inspect the hidden field {{error}}.$response on this object.",
752 crate::json_parse::js_parse_error(body).unwrap_or_default()
753 ),
754 status: Some(status),
755 code: None,
756 request_id,
757 retryable: matches!(status, 429 | 500 | 502 | 503 | 504),
758 };
759 };
760 let sanitize = |code: &str| {
761 let code = code.split(':').next().unwrap_or(code);
762 code.rsplit('#').next().unwrap_or(code).to_owned()
763 };
764 let name = header("x-amzn-errortype")
765 .map(|code| sanitize(&code))
766 .or_else(|| json["code"].as_str().map(sanitize))
767 .or_else(|| json["__type"].as_str().map(sanitize))
768 .unwrap_or_else(|| "Unknown".to_owned());
769 let message = json["message"]
770 .as_str()
771 .or_else(|| json["Message"].as_str())
772 .unwrap_or("UnknownError");
773 Failure {
774 message: service_message(&name, message),
775 status: Some(status),
776 code: name.ends_with("Exception").then(|| name.clone()),
777 request_id,
778 retryable: matches!(status, 429 | 500 | 502 | 503 | 504)
779 || THROTTLING_CODES.contains(&name.as_str()),
780 }
781}
782
783fn diagnostic_value(value: &str) -> Option<String> {
785 let trimmed = value.trim();
786 (!trimmed.is_empty() && trimmed.chars().count() <= 200).then(|| trimmed.to_owned())
787}
788
789fn append_diagnostic(
790 output: &mut AssistantMessage,
791 failure: &Failure,
792 fallback_request_id: Option<&str>,
793) {
794 let mut details = Map::new();
795 if let Some(status) = failure.status {
796 details.insert("status".into(), json!(status));
797 }
798 if let Some(code) = &failure.code {
799 details.insert("errorCode".into(), json!(code));
800 }
801 if let Some(id) = failure
802 .request_id
803 .clone()
804 .or_else(|| fallback_request_id.map(str::to_owned))
805 {
806 details.insert("requestId".into(), json!(id));
807 }
808 if details.is_empty() {
809 return;
810 }
811 output.diagnostics.get_or_insert_with(Vec::new).push(json!({
812 "type": "bedrock_response_failure",
813 "timestamp": now_ms(),
814 "details": details,
815 }));
816}
817
818fn max_attempts(options: &StreamOptions) -> u32 {
820 provider_env_value("AWS_MAX_ATTEMPTS", options.env.as_ref())
821 .and_then(|value| value.parse().ok())
822 .filter(|attempts: &u32| *attempts > 0)
823 .unwrap_or(3)
824}
825
826fn retry_delay(attempt: u32, throttled: bool) -> Duration {
829 let base: u64 = if throttled { 500 } else { 100 };
830 let ceiling = (base << attempt.min(16)).min(20_000);
831 let random = u64::from_le_bytes(yapi_types::time::try_random_bytes().unwrap_or_default());
833 let fraction = (random % 10_000) as f64 / 10_000.0;
834 Duration::from_millis((ceiling as f64 * fraction) as u64)
835}
836
837async fn send(
839 target: &Target,
840 body: &str,
841 options: &StreamOptions,
842) -> Result<reqwest::Response, Failure> {
843 let attempts = max_attempts(options);
844 let mut attempt = 1;
845 loop {
846 let headers = signed_headers(target, body, options).map_err(Failure::plain)?;
847 let request = headers.apply(http::client().post(&target.url).body(body.to_owned()));
848 let Some(result) = options.cancel.run_until_cancelled(request.send()).await else {
849 return Err(aborted());
850 };
851 let failure = match result {
852 Ok(response) if response.status().is_success() => return Ok(response),
853 Ok(response) => {
854 let status = response.status().as_u16();
855 let headers = response.headers().clone();
856 let text = response.text().await.unwrap_or_default();
857 response_failure(status, &headers, &text)
858 }
859 Err(err) => Failure {
860 message: crate::auth::network_message(&err),
861 status: None,
862 code: None,
863 request_id: None,
864 retryable: true,
865 },
866 };
867 if !failure.retryable || attempt >= attempts {
868 return Err(failure);
869 }
870 let throttled = failure.status == Some(429)
871 || failure
872 .code
873 .as_deref()
874 .is_some_and(|code| THROTTLING_CODES.contains(&code));
875 options
876 .cancel
877 .run_until_cancelled(tokio::time::sleep(retry_delay(attempt, throttled)))
878 .await
879 .ok_or_else(aborted)?;
880 attempt += 1;
881 }
882}
883
884fn aborted() -> Failure {
885 Failure::plain(http::ABORTED_BEFORE_RESPONSE.into())
886}
887
888struct Open {
890 index: Option<u64>,
891 partial_json: String,
892 redacted: Vec<u8>,
893}
894
895struct State {
896 output: AssistantMessage,
897 open: Vec<Open>,
898 started: bool,
899}
900
901impl State {
902 fn position(&self, index: u64) -> Option<usize> {
903 self.open.iter().position(|open| open.index == Some(index))
904 }
905
906 fn push(&mut self, block: ContentBlock, index: u64, sender: &EventSender) -> usize {
907 self.open.push(Open {
908 index: Some(index),
909 partial_json: String::new(),
910 redacted: Vec::new(),
911 });
912 sender.start(&mut self.output, block)
913 }
914
915 fn flush_redacted(&mut self, position: usize) {
916 let redacted = std::mem::take(&mut self.open[position].redacted);
917 if let ContentBlock::Thinking(thinking) = &mut self.output.content[position]
918 && thinking.redacted == Some(true)
919 {
920 thinking.thinking_signature =
921 Some(base64::engine::general_purpose::STANDARD.encode(redacted));
922 }
923 }
924
925 fn finalize(&mut self) {
928 for position in 0..self.open.len() {
929 if self.open[position].index.take().is_some()
930 && let ContentBlock::ToolCall(call) = &mut self.output.content[position]
931 {
932 call.arguments = parse_streaming_json(&self.open[position].partial_json);
933 }
934 if !self.open[position].redacted.is_empty() {
935 self.flush_redacted(position);
936 }
937 }
938 }
939
940 fn handle(
941 &mut self,
942 kind: &str,
943 event: &Value,
944 model: &Model,
945 sender: &EventSender,
946 ) -> Result<(), String> {
947 match kind {
948 "messageStart" => {
949 if event["role"] != "assistant" {
950 return Err(
951 "Unexpected assistant message start but got user message start instead"
952 .into(),
953 );
954 }
955 self.started = true;
956 sender.send(StreamEvent::Start(self.output.clone()));
957 }
958 "contentBlockStart" => {
959 let index = event["contentBlockIndex"].as_u64().unwrap_or_default();
960 let tool = &event["start"]["toolUse"];
961 if tool.is_object() {
962 let call = ToolCall {
963 id: tool["toolUseId"].as_str().unwrap_or_default().to_owned(),
964 name: tool["name"].as_str().unwrap_or_default().to_owned(),
965 arguments: Map::new(),
966 thought_signature: None,
967 namespace: None,
968 };
969 self.push(ContentBlock::ToolCall(call), index, sender);
970 }
971 }
972 "contentBlockDelta" => self.delta(event, sender),
973 "contentBlockStop" => {
974 let Some(position) = event["contentBlockIndex"]
975 .as_u64()
976 .and_then(|i| self.position(i))
977 else {
978 return Ok(());
979 };
980 self.open[position].index = None;
981 match &mut self.output.content[position] {
982 ContentBlock::Thinking(_) => self.flush_redacted(position),
983 ContentBlock::ToolCall(call) => {
984 call.arguments = parse_streaming_json(&self.open[position].partial_json);
985 }
986 _ => {}
987 }
988 sender.end(&self.output, position);
989 }
990 "messageStop" => {
991 let reason = event["stopReason"].as_str();
992 self.output.raw_stop_reason = reason.map(str::to_owned);
993 self.output.stop_reason = match reason {
994 Some("end_turn" | "stop_sequence") => StopReason::Stop,
995 Some("max_tokens" | "model_context_window_exceeded") => StopReason::Length,
996 Some("tool_use") => StopReason::ToolUse,
997 Some(other) => {
998 self.output.error_message = Some(format!("Provider stopped with: {other}"));
999 StopReason::Error
1000 }
1001 None => StopReason::Error,
1002 };
1003 }
1004 "metadata" => {
1005 let usage_json = &event["usage"];
1006 if usage_json.is_object() {
1007 let usage = &mut self.output.usage;
1008 let count = |name: &str| usage_json[name].as_u64().unwrap_or(0);
1009 usage.input = count("inputTokens");
1010 usage.output = count("outputTokens");
1011 usage.cache_read = count("cacheReadInputTokens");
1012 usage.cache_write = count("cacheWriteInputTokens");
1013 usage.cache_write_1h = usage_json["cacheDetails"].as_array().map(|details| {
1014 details
1015 .iter()
1016 .filter(|detail| detail["ttl"] == "1h")
1017 .map(|detail| detail["inputTokens"].as_u64().unwrap_or(0))
1018 .sum()
1019 });
1020 let total = count("totalTokens");
1021 usage.total_tokens = Some(if total > 0 {
1022 total
1023 } else {
1024 usage.input + usage.output
1025 });
1026 calculate_cost(model, usage);
1027 }
1028 }
1029 _ => {}
1030 }
1031 Ok(())
1032 }
1033
1034 fn delta(&mut self, event: &Value, sender: &EventSender) {
1035 let index = event["contentBlockIndex"].as_u64().unwrap_or_default();
1036 let delta = &event["delta"];
1037 let position = self.position(index);
1038 if let Some(text) = delta["text"].as_str() {
1039 let position =
1040 position.unwrap_or_else(|| self.push(ContentBlock::text(""), index, sender));
1041 if let ContentBlock::Text(_) = self.output.content[position] {
1042 sender.delta(&mut self.output, position, text);
1043 }
1044 } else if delta["toolUse"].is_object() {
1045 let Some(position) = position else {
1046 return;
1047 };
1048 let piece = delta["toolUse"]["input"].as_str().unwrap_or_default();
1049 self.open[position].partial_json.push_str(piece);
1050 if let ContentBlock::ToolCall(_) = self.output.content[position] {
1051 sender.delta(&mut self.output, position, piece);
1052 }
1053 } else if delta["reasoningContent"].is_object() {
1054 let reasoning = &delta["reasoningContent"];
1055 let position = position.unwrap_or_else(|| {
1056 let block = ContentBlock::thinking("", Some(String::new()));
1057 self.push(block, index, sender)
1058 });
1059 let ContentBlock::Thinking(_) = self.output.content[position] else {
1060 return;
1061 };
1062 if let Some(text) = reasoning["text"].as_str().filter(|text| !text.is_empty()) {
1063 sender.delta(&mut self.output, position, text);
1064 }
1065 let ContentBlock::Thinking(thinking) = &mut self.output.content[position] else {
1066 return;
1067 };
1068 if let Some(signature) = reasoning["signature"].as_str().filter(|s| !s.is_empty())
1069 && thinking.redacted != Some(true)
1070 {
1071 thinking
1072 .thinking_signature
1073 .get_or_insert_with(String::new)
1074 .push_str(signature);
1075 }
1076 if let Some(redacted) = reasoning["redactedContent"]
1077 .as_str()
1078 .and_then(|data| base64::engine::general_purpose::STANDARD.decode(data).ok())
1079 .filter(|bytes| !bytes.is_empty())
1080 {
1081 if thinking.redacted != Some(true) {
1082 thinking.redacted = Some(true);
1083 thinking.thinking_signature = Some(String::new());
1084 sender.delta(&mut self.output, position, REDACTED_THINKING);
1085 }
1086 self.open[position].redacted.extend(redacted);
1087 }
1088 }
1089 }
1090}
1091
1092fn exception_name(kind: &str) -> String {
1094 let mut chars = kind.chars();
1095 chars
1096 .next()
1097 .map(|first| first.to_uppercase().chain(chars).collect())
1098 .unwrap_or_default()
1099}
1100
1101async fn consume(
1102 state: &mut State,
1103 mut response: reqwest::Response,
1104 model: &Model,
1105 sender: &EventSender,
1106 options: &StreamOptions,
1107) -> Result<(), Failure> {
1108 let plain = Failure::plain;
1109 let mut decoder = eventstream::Decoder::default();
1110 loop {
1111 let chunk =
1112 match http::read_chunk(&mut response, &options.cancel, http::ABORTED_DURING_STREAM)
1113 .await
1114 {
1115 Ok(chunk) => chunk,
1116 Err(message) => return Err(plain(message)),
1117 };
1118 let Some(bytes) = chunk else {
1119 decoder.finish().map_err(plain)?;
1120 break;
1121 };
1122 decoder.push(&bytes);
1123 while let Some(message) = decoder.next_message().map_err(plain)? {
1124 let payload: Value = serde_json::from_slice(&message.payload).unwrap_or(Value::Null);
1125 match message.header(":message-type") {
1126 Some("event") => {
1127 let kind = message.header(":event-type").unwrap_or_default();
1128 let mut item = serde_json::Map::new();
1129 item.insert(kind.to_owned(), payload.clone());
1130 options.hooks.stream_event(&Value::Object(item)).await;
1131 state.handle(kind, &payload, model, sender).map_err(plain)?;
1132 }
1133 Some("exception") => {
1134 let name =
1135 exception_name(message.header(":exception-type").unwrap_or_default());
1136 let text = payload["message"]
1137 .as_str()
1138 .or_else(|| payload["Message"].as_str())
1139 .unwrap_or("UnknownError");
1140 return Err(Failure {
1141 message: service_message(&name, text),
1142 status: None,
1143 code: name.ends_with("Exception").then(|| name.clone()),
1144 request_id: None,
1145 retryable: false,
1146 });
1147 }
1148 Some("error") => {
1149 let code = message
1150 .header(":error-code")
1151 .unwrap_or("UnknownError")
1152 .to_owned();
1153 let text = message.header(":error-message").unwrap_or_default();
1154 return Err(Failure {
1155 message: text.to_owned(),
1156 status: None,
1157 code: code.ends_with("Exception").then_some(code),
1158 request_id: None,
1159 retryable: false,
1160 });
1161 }
1162 _ => {}
1163 }
1164 }
1165 }
1166 check_complete(
1167 &state.output,
1168 &options.cancel,
1169 "Bedrock stream ended without a stop reason",
1170 )
1171 .map_err(plain)
1172}
1173
1174pub(super) async fn run(request: Request, sender: EventSender) {
1175 let Request {
1176 model,
1177 messages,
1178 options,
1179 } = request;
1180 let fail = |mut output: AssistantMessage, failure: Failure, request_id: Option<&str>| {
1181 if !options.cancel.is_cancelled() {
1182 append_diagnostic(&mut output, &failure, request_id);
1183 }
1184 send_error(&sender, output, &options.cancel, failure.message);
1185 };
1186 let output = new_output(&model, now_ms());
1187 let response = match connect(&model, &messages, &options).await {
1188 Ok(response) => response,
1189 Err(failure) => return fail(output, failure, None),
1190 };
1191 options.hooks.response(&response).await;
1192 let request_id = response
1193 .headers()
1194 .get("x-amzn-requestid")
1195 .and_then(|value| value.to_str().ok())
1196 .and_then(diagnostic_value);
1197 let mut state = State {
1198 output,
1199 open: Vec::new(),
1200 started: false,
1201 };
1202 let result = consume(&mut state, response, &model, &sender, &options).await;
1203 state.finalize();
1204 match result {
1205 Ok(()) => sender.send(StreamEvent::Done(state.output)),
1206 Err(failure) => fail(state.output, failure, request_id.as_deref()),
1207 }
1208}
1209
1210async fn connect(
1213 model: &Model,
1214 messages: &[Message],
1215 options: &StreamOptions,
1216) -> Result<reqwest::Response, Failure> {
1217 let configured_region = provider_env_value("AWS_REGION", options.env.as_ref())
1218 .or_else(|| provider_env_value("AWS_DEFAULT_REGION", options.env.as_ref()));
1219 let thinking = thinking(model, messages, options);
1220 let body = build_body(
1221 model,
1222 messages,
1223 options,
1224 &thinking,
1225 configured_region.as_deref(),
1226 )
1227 .map_err(Failure::plain)?;
1228 let mut input = options.hooks.payload(body).await;
1229 let model_id = match input
1231 .as_object_mut()
1232 .and_then(|input| input.shift_remove("modelId"))
1233 {
1234 Some(Value::String(id)) => id,
1235 _ => model.id.clone(),
1236 };
1237 let body = yapi_types::json::stringify(&input);
1238 let target = target(model, &model_id, options)
1239 .await
1240 .map_err(Failure::plain)?;
1241 send(&target, &body, options).await
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247
1248 #[test]
1249 fn resolves_regions() {
1250 assert_eq!(
1251 endpoint_region("https://bedrock-runtime.eu-central-1.amazonaws.com").as_deref(),
1252 Some("eu-central-1")
1253 );
1254 assert_eq!(
1255 endpoint_region("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com").as_deref(),
1256 Some("us-gov-west-1")
1257 );
1258 assert_eq!(endpoint_region("http://127.0.0.1:9"), None);
1259 assert_eq!(
1260 arn_region("arn:aws:bedrock:us-west-2:123:application-inference-profile/x").as_deref(),
1261 Some("us-west-2")
1262 );
1263 assert_eq!(
1264 arn_region("arn:aws-us-gov:bedrock:us-gov-east-1:1:x").as_deref(),
1265 Some("us-gov-east-1")
1266 );
1267 assert_eq!(arn_region("anthropic.claude"), None);
1268 }
1269
1270 #[test]
1271 fn formats_errors_like_sdk() {
1272 let mut headers = reqwest::header::HeaderMap::new();
1273 headers.insert(
1274 "x-amzn-errortype",
1275 "ValidationException:http://internal.amazon.com/coral/com.amazon.bedrock/"
1276 .parse()
1277 .unwrap(),
1278 );
1279 headers.insert("x-amzn-requestid", "rid-400".parse().unwrap());
1280 let failure = response_failure(
1281 400,
1282 &headers,
1283 r#"{"message":"The provided model identifier is invalid."}"#,
1284 );
1285 assert_eq!(
1286 failure.message,
1287 "Validation error: The provided model identifier is invalid."
1288 );
1289 assert_eq!(failure.code.as_deref(), Some("ValidationException"));
1290 assert_eq!(failure.request_id.as_deref(), Some("rid-400"));
1291 let none = reqwest::header::HeaderMap::new();
1292 assert_eq!(
1293 response_failure(403, &none, r#"{"message":"Forbidden"}"#).message,
1294 "Unknown: Forbidden"
1295 );
1296 assert_eq!(
1297 response_failure(403, &none, "").message,
1298 "Unknown: UnknownError"
1299 );
1300 assert_eq!(
1301 response_failure(400, &none, r#"{"code":"SomeError","message":"x"}"#).message,
1302 "SomeError: x"
1303 );
1304 assert_eq!(
1305 response_failure(400, &none, r#"{"__type":"com.amazon.coral.validate#ValidationException","message":"bad input"}"#).message,
1306 "Validation error: bad input"
1307 );
1308 assert_eq!(
1309 response_failure(502, &none, "<html>Bad Gateway</html>").message,
1310 "Unexpected token '<', \"<html>Bad \"... is not valid JSON\n Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object."
1311 );
1312 assert!(response_failure(502, &none, "x").retryable);
1313 assert!(!response_failure(403, &none, "{}").retryable);
1314 assert_eq!(exception_name("throttlingException"), "ThrottlingException");
1315 }
1316}