1#![allow(clippy::cast_possible_truncation)]
28
29use bytes::Bytes;
30use serde_json::{Map, Value, json};
31
32use crate::codecs::codec::{Codec, EncodedRequest};
33use crate::error::{Error, Result};
34use crate::ir::{
35 Capabilities, ContentPart, MediaSource, ModelRequest, ModelResponse, ModelWarning,
36 OutputStrategy, ProviderEchoSnapshot, ReasoningEffort, RefusalReason, ResponseFormat, Role,
37 StopReason, ToolChoice, ToolKind, ToolResultContent, Usage,
38};
39use crate::rate_limit::RateLimitSnapshot;
40
41const PROVIDER_KEY: &str = "bedrock-converse";
47
48const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 200_000;
49
50#[derive(Clone, Copy, Debug, Default)]
52pub struct BedrockConverseCodec;
53
54impl BedrockConverseCodec {
55 pub const fn new() -> Self {
57 Self
58 }
59}
60
61impl Codec for BedrockConverseCodec {
62 fn name(&self) -> &'static str {
63 PROVIDER_KEY
64 }
65
66 fn capabilities(&self, _model: &str) -> Capabilities {
67 Capabilities {
68 streaming: true,
69 tools: true,
70 multimodal_image: true,
71 multimodal_audio: false,
72 multimodal_video: false,
73 multimodal_document: true,
74 system_prompt: true,
75 structured_output: true,
76 prompt_caching: true,
77 thinking: true,
78 citations: true,
79 web_search: true,
80 computer_use: true,
81 max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS,
82 }
83 }
84
85 fn auto_output_strategy(&self, model: &str) -> OutputStrategy {
86 if is_bedrock_anthropic(model) {
93 OutputStrategy::Tool
94 } else {
95 OutputStrategy::Native
96 }
97 }
98
99 fn encode(&self, request: &ModelRequest) -> Result<EncodedRequest> {
100 let (body, warnings) = build_body(request)?;
101 finalize_request(&request.model, &body, warnings, false)
102 }
103
104 fn encode_streaming(&self, request: &ModelRequest) -> Result<EncodedRequest> {
105 let (body, warnings) = build_body(request)?;
106 let mut encoded = finalize_request(&request.model, &body, warnings, true)?;
107 encoded.headers.insert(
108 http::header::ACCEPT,
109 http::HeaderValue::from_static("application/vnd.amazon.eventstream"),
110 );
111 Ok(encoded.into_streaming())
112 }
113
114 fn decode(&self, body: &[u8], warnings_in: Vec<ModelWarning>) -> Result<ModelResponse> {
115 let raw: Value = super::codec::parse_response_body(body, "Bedrock Converse")?;
116 let mut warnings = warnings_in;
117 let id = String::new(); let model = String::new(); let usage = decode_usage(raw.get("usage"));
120 let (content, stop_reason) = decode_output(&raw, &mut warnings);
121 Ok(ModelResponse {
122 id,
123 model,
124 stop_reason,
125 content,
126 usage,
127 rate_limit: None,
128 warnings,
129 provider_echoes: Vec::new(),
130 })
131 }
132
133 fn extract_rate_limit(&self, headers: &http::HeaderMap) -> Option<RateLimitSnapshot> {
134 let mut snapshot = RateLimitSnapshot::default();
143 let mut populated = false;
144 for (name, value) in headers {
145 let header_name = name.as_str();
146 if !header_name.starts_with("x-amzn-bedrock-") {
147 continue;
148 }
149 if let Ok(v) = value.to_str() {
150 snapshot.raw.insert(header_name.to_owned(), v.to_owned());
151 populated = true;
152 }
153 }
154 if let Some(v) = headers.get("retry-after").and_then(|h| h.to_str().ok()) {
158 snapshot.raw.insert("retry-after".into(), v.to_owned());
159 populated = true;
160 }
161 populated.then_some(snapshot)
162 }
163}
164
165fn build_body(request: &ModelRequest) -> Result<(Value, Vec<ModelWarning>)> {
168 if request.messages.is_empty() && request.system.is_empty() {
169 return Err(Error::invalid_request(
170 "Bedrock Converse requires at least one message",
171 ));
172 }
173 let mut warnings = Vec::new();
174 let (system_blocks, messages) = encode_messages(request, &mut warnings);
175
176 let mut body = Map::new();
177 body.insert("messages".into(), Value::Array(messages));
178 if !system_blocks.is_empty() {
179 body.insert("system".into(), Value::Array(system_blocks));
180 }
181
182 let mut inference_config = Map::new();
183 if let Some(t) = request.max_tokens {
184 inference_config.insert("maxTokens".into(), json!(t));
185 }
186 if let Some(t) = request.temperature {
187 inference_config.insert("temperature".into(), json!(t));
188 }
189 if let Some(p) = request.top_p {
190 inference_config.insert("topP".into(), json!(p));
191 }
192 if !request.stop_sequences.is_empty() {
193 inference_config.insert("stopSequences".into(), json!(request.stop_sequences));
194 }
195 if !inference_config.is_empty() {
196 body.insert("inferenceConfig".into(), Value::Object(inference_config));
197 }
198 if let Some(k) = request.top_k {
199 if is_bedrock_anthropic(&request.model) {
206 let mut additional = body
207 .remove("additionalModelRequestFields")
208 .and_then(|v| match v {
209 Value::Object(o) => Some(o),
210 _ => None,
211 })
212 .unwrap_or_default(); additional.insert("top_k".into(), json!(k));
214 body.insert(
215 "additionalModelRequestFields".into(),
216 Value::Object(additional),
217 );
218 } else {
219 warnings.push(ModelWarning::LossyEncode {
220 field: "top_k".into(),
221 detail: "Bedrock Converse non-Anthropic models have no top_k parameter — \
222 setting dropped"
223 .into(),
224 });
225 }
226 }
227
228 if !request.tools.is_empty() {
229 let mut tool_config = Map::new();
230 tool_config.insert("tools".into(), encode_tools(&request.tools, &mut warnings));
231 tool_config.insert(
232 "toolChoice".into(),
233 encode_tool_choice(&request.tool_choice),
234 );
235 body.insert("toolConfig".into(), Value::Object(tool_config));
236 }
237 if let Some(format) = &request.response_format {
238 encode_bedrock_structured_output(format, &request.model, &mut body, &mut warnings)?;
239 }
240 if let Some(effort) = &request.reasoning_effort {
241 encode_bedrock_thinking(&request.model, effort, &mut body, &mut warnings);
242 }
243 apply_provider_extensions(request, &mut body, &mut warnings);
244 Ok((Value::Object(body), warnings))
245}
246
247fn encode_bedrock_structured_output(
257 format: &ResponseFormat,
258 model: &str,
259 body: &mut Map<String, Value>,
260 warnings: &mut Vec<ModelWarning>,
261) -> Result<()> {
262 let is_anthropic = is_bedrock_anthropic(model);
263 let strategy = match format.strategy {
264 OutputStrategy::Auto => {
265 if is_anthropic {
266 OutputStrategy::Tool
267 } else {
268 OutputStrategy::Native
269 }
270 }
271 explicit => explicit,
272 };
273 if !is_anthropic {
274 warnings.push(ModelWarning::LossyEncode {
280 field: "response_format".into(),
281 detail: format!(
282 "Bedrock model {model:?} is not in the Anthropic family — Bedrock has no \
283 structured-output channel for non-Anthropic models on Converse; field dropped"
284 ),
285 });
286 return Ok(());
287 }
288 let mut additional = body
289 .remove("additionalModelRequestFields")
290 .and_then(|v| match v {
291 Value::Object(o) => Some(o),
292 _ => None,
293 })
294 .unwrap_or_default(); let stripped = crate::LlmFacingSchema::strip(&format.json_schema.schema);
301 match strategy {
302 OutputStrategy::Native => {
303 additional.insert(
304 "output_config".into(),
305 json!({
306 "format": {
307 "type": "json_schema",
308 "schema": stripped,
309 }
310 }),
311 );
312 if !format.strict {
313 warnings.push(ModelWarning::LossyEncode {
314 field: "response_format.strict".into(),
315 detail: "Anthropic-on-Bedrock always strict-validates structured output; \
316 the strict=false request was approximated"
317 .into(),
318 });
319 }
320 }
321 OutputStrategy::Tool => {
322 let tool_name = format.json_schema.name.clone();
330 additional.insert(
331 "tools".into(),
332 json!([{
333 "type": "custom",
334 "name": tool_name,
335 "description": format!(
336 "Emit the response as a JSON object matching the {tool_name} schema."
337 ),
338 "input_schema": stripped,
339 }]),
340 );
341 additional.insert(
342 "tool_choice".into(),
343 json!({
344 "type": "tool",
345 "name": format.json_schema.name,
346 "disable_parallel_tool_use": true,
347 }),
348 );
349 if !format.strict {
350 warnings.push(ModelWarning::LossyEncode {
351 field: "response_format.strict".into(),
352 detail: "Bedrock-Anthropic Tool-strategy structured output is always \
353 schema-validated; strict=false was approximated"
354 .into(),
355 });
356 }
357 }
358 OutputStrategy::Prompted => {
359 return Err(Error::invalid_request(
360 "OutputStrategy::Prompted is deferred to entelix 1.1; use \
361 OutputStrategy::Native or OutputStrategy::Tool",
362 ));
363 }
364 OutputStrategy::Auto => unreachable!("Auto resolved above"),
365 }
366 body.insert(
367 "additionalModelRequestFields".into(),
368 Value::Object(additional),
369 );
370 Ok(())
371}
372
373fn is_bedrock_anthropic(model: &str) -> bool {
379 model.contains("anthropic.claude-")
383}
384
385fn is_bedrock_anthropic_adaptive_only(model: &str) -> bool {
388 is_bedrock_anthropic(model) && model.contains("claude-opus-4-7")
389}
390
391fn encode_bedrock_thinking(
397 model: &str,
398 effort: &ReasoningEffort,
399 body: &mut Map<String, Value>,
400 warnings: &mut Vec<ModelWarning>,
401) {
402 if !is_bedrock_anthropic(model) {
403 warnings.push(ModelWarning::LossyEncode {
404 field: "reasoning_effort".into(),
405 detail: format!(
406 "Bedrock model {model:?} is not in the Anthropic family — Bedrock has no \
407 thinking knob for non-Anthropic models; field dropped"
408 ),
409 });
410 return;
411 }
412 let adaptive_only = is_bedrock_anthropic_adaptive_only(model);
413 let thinking = match effort {
414 ReasoningEffort::Off => json!({"type": "disabled"}),
415 ReasoningEffort::Minimal => {
416 warnings.push(ModelWarning::LossyEncode {
417 field: "reasoning_effort".into(),
418 detail: "Anthropic on Bedrock has no `Minimal` bucket — snapped to adaptive `low`"
419 .into(),
420 });
421 json!({"type": "adaptive", "effort": "low"})
422 }
423 ReasoningEffort::Low => {
424 if adaptive_only {
425 json!({"type": "adaptive", "effort": "low"})
426 } else {
427 json!({"type": "enabled", "budget_tokens": 1024})
428 }
429 }
430 ReasoningEffort::Medium => {
431 if adaptive_only {
432 json!({"type": "adaptive", "effort": "medium"})
433 } else {
434 json!({"type": "enabled", "budget_tokens": 4096})
435 }
436 }
437 ReasoningEffort::High => {
438 if adaptive_only {
439 json!({"type": "adaptive", "effort": "high"})
440 } else {
441 json!({"type": "enabled", "budget_tokens": 16384})
442 }
443 }
444 ReasoningEffort::Auto => json!({"type": "adaptive"}),
445 ReasoningEffort::VendorSpecific(literal) => {
446 if adaptive_only {
447 warnings.push(ModelWarning::LossyEncode {
448 field: "reasoning_effort".into(),
449 detail: format!(
450 "Bedrock-Anthropic {model} is adaptive-only — manual budget \
451 {literal:?} dropped; emitting `{{type:\"adaptive\"}}` instead"
452 ),
453 });
454 json!({"type": "adaptive"})
455 } else if let Ok(budget) = literal.parse::<u32>() {
456 json!({"type": "enabled", "budget_tokens": budget})
457 } else {
458 warnings.push(ModelWarning::LossyEncode {
459 field: "reasoning_effort".into(),
460 detail: format!(
461 "Bedrock-Anthropic vendor-specific reasoning_effort {literal:?} is not \
462 a numeric budget_tokens — falling through to `Medium`"
463 ),
464 });
465 json!({"type": "enabled", "budget_tokens": 4096})
466 }
467 }
468 };
469 let mut additional = body
470 .remove("additionalModelRequestFields")
471 .and_then(|v| match v {
472 Value::Object(o) => Some(o),
473 _ => None,
474 })
475 .unwrap_or_default(); additional.insert("thinking".into(), thinking);
477 body.insert(
478 "additionalModelRequestFields".into(),
479 Value::Object(additional),
480 );
481}
482
483fn apply_provider_extensions(
488 request: &ModelRequest,
489 body: &mut Map<String, Value>,
490 warnings: &mut Vec<ModelWarning>,
491) {
492 let ext = &request.provider_extensions;
493 if let Some(bedrock) = &ext.bedrock {
494 if let Some(guardrail) = &bedrock.guardrail {
495 body.insert(
496 "guardrailConfig".into(),
497 json!({
498 "guardrailIdentifier": guardrail.identifier,
499 "guardrailVersion": guardrail.version,
500 }),
501 );
502 }
503 if let Some(tier) = &bedrock.performance_config_tier {
504 body.insert("performanceConfig".into(), json!({ "latency": tier }));
505 }
506 }
507 if request.parallel_tool_calls.is_some() {
512 warnings.push(ModelWarning::LossyEncode {
513 field: "parallel_tool_calls".into(),
514 detail: "Bedrock Converse exposes no equivalent toggle — \
515 setting dropped on the wire"
516 .into(),
517 });
518 }
519 if let Some(user_id) = &request.end_user_id {
520 if is_bedrock_anthropic(&request.model) {
521 let entry = body
526 .entry("additionalModelRequestFields")
527 .or_insert_with(|| Value::Object(Map::new()));
528 if let Some(map) = entry.as_object_mut() {
529 let metadata = map
530 .entry("metadata")
531 .or_insert_with(|| Value::Object(Map::new()));
532 if let Some(meta_map) = metadata.as_object_mut() {
533 meta_map.insert("user_id".into(), Value::String(user_id.clone()));
534 }
535 }
536 } else {
537 warnings.push(ModelWarning::LossyEncode {
538 field: "end_user_id".into(),
539 detail: "Bedrock Converse non-Anthropic models have no per-request end-user \
540 attribution channel — setting dropped"
541 .into(),
542 });
543 }
544 }
545 if request.seed.is_some() {
546 warnings.push(ModelWarning::LossyEncode {
547 field: "seed".into(),
548 detail: "Bedrock Converse has no deterministic-sampling knob — setting dropped".into(),
549 });
550 }
551 if ext.openai_chat.is_some() {
552 warnings.push(ModelWarning::ProviderExtensionIgnored {
553 vendor: "openai_chat".into(),
554 });
555 }
556 if ext.openai_responses.is_some() {
557 warnings.push(ModelWarning::ProviderExtensionIgnored {
558 vendor: "openai_responses".into(),
559 });
560 }
561 if ext.gemini.is_some() {
562 warnings.push(ModelWarning::ProviderExtensionIgnored {
563 vendor: "gemini".into(),
564 });
565 }
566}
567
568fn finalize_request(
569 model: &str,
570 body: &Value,
571 warnings: Vec<ModelWarning>,
572 streaming: bool,
573) -> Result<EncodedRequest> {
574 let bytes = serde_json::to_vec(body)?;
575 let path = if streaming {
576 format!("/model/{model}/converse-stream")
577 } else {
578 format!("/model/{model}/converse")
579 };
580 let mut encoded = EncodedRequest::post_json(path, Bytes::from(bytes));
581 encoded.warnings = warnings;
582 Ok(encoded)
583}
584
585fn encode_messages(
588 request: &ModelRequest,
589 warnings: &mut Vec<ModelWarning>,
590) -> (Vec<Value>, Vec<Value>) {
591 let mut system_blocks: Vec<Value> = Vec::new();
597 for (idx, block) in request.system.blocks().iter().enumerate() {
598 system_blocks.push(json!({ "text": block.text.clone() }));
599 attach_cache_point(
600 &mut system_blocks,
601 block.cache_control,
602 || format!("system[{idx}]"),
603 warnings,
604 );
605 }
606 let mut messages = Vec::new();
607
608 for (idx, msg) in request.messages.iter().enumerate() {
609 match msg.role {
610 Role::System => {
611 let mut text = String::new();
612 let mut lossy = false;
613 for part in &msg.content {
614 if let ContentPart::Text { text: t, .. } = part {
615 text.push_str(t);
616 } else {
617 lossy = true;
618 }
619 }
620 if lossy {
621 warnings.push(ModelWarning::LossyEncode {
622 field: format!("messages[{idx}].content"),
623 detail: "non-text parts dropped from system message (Bedrock routes \
624 system into top-level system array)"
625 .into(),
626 });
627 }
628 if !text.is_empty() {
629 system_blocks.push(json!({ "text": text }));
630 }
631 }
632 Role::User => {
633 messages.push(json!({
634 "role": "user",
635 "content": encode_user_content(&msg.content, warnings, idx),
636 }));
637 }
638 Role::Assistant => {
639 messages.push(json!({
640 "role": "assistant",
641 "content": encode_assistant_content(&msg.content, warnings, idx),
642 }));
643 }
644 Role::Tool => {
645 messages.push(json!({
646 "role": "user",
647 "content": encode_tool_results(&msg.content, warnings, idx),
648 }));
649 }
650 }
651 }
652 (system_blocks, messages)
653}
654
655fn encode_user_content(
656 parts: &[ContentPart],
657 warnings: &mut Vec<ModelWarning>,
658 msg_idx: usize,
659) -> Vec<Value> {
660 let mut out = Vec::new();
661 for (part_idx, part) in parts.iter().enumerate() {
662 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
663 let cache = content_part_cache_control(part);
664 match part {
665 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
666 ContentPart::Image { source, .. } => match source {
667 MediaSource::Base64 { media_type, data } => {
668 let format_str = media_type.split('/').next_back().unwrap_or("png"); out.push(json!({
670 "image": {
671 "format": format_str,
672 "source": { "bytes": data },
673 },
674 }));
675 }
676 MediaSource::Url { url, .. } => warnings.push(ModelWarning::LossyEncode {
677 field: path(),
678 detail: format!(
679 "Bedrock Converse requires base64 inline image bytes; URL '{url}' dropped"
680 ),
681 }),
682 MediaSource::FileId { .. } => warnings.push(ModelWarning::LossyEncode {
683 field: path(),
684 detail: "Bedrock Converse does not accept FileId image input".into(),
685 }),
686 },
687 ContentPart::Audio { .. } => warnings.push(ModelWarning::LossyEncode {
688 field: path(),
689 detail: "Bedrock Converse does not accept audio inputs; block dropped".into(),
690 }),
691 ContentPart::Video { .. } => warnings.push(ModelWarning::LossyEncode {
692 field: path(),
693 detail: "Bedrock Converse video input is not declared in the codec's default \
694 capability set (Nova-series models only); block dropped"
695 .into(),
696 }),
697 ContentPart::Document { source, name, .. } => match source {
698 MediaSource::Base64 { media_type, data } => {
699 let format_str = media_type.split('/').next_back().unwrap_or("pdf"); let mut inner = Map::new();
701 inner.insert("format".into(), Value::String(format_str.into()));
702 if let Some(n) = name {
703 inner.insert("name".into(), Value::String(n.clone()));
704 }
705 inner.insert("source".into(), json!({ "bytes": data }));
706 out.push(json!({ "document": Value::Object(inner) }));
707 }
708 _ => warnings.push(ModelWarning::LossyEncode {
709 field: path(),
710 detail: "Bedrock Converse document accepts only base64 inline; URL/FileId \
711 dropped"
712 .into(),
713 }),
714 },
715 ContentPart::Thinking { .. } => warnings.push(ModelWarning::LossyEncode {
716 field: path(),
717 detail: "Bedrock Converse does not accept thinking blocks on input; block dropped"
718 .into(),
719 }),
720 ContentPart::Citation { .. } => warnings.push(ModelWarning::LossyEncode {
721 field: path(),
722 detail: "Bedrock Converse does not echo citations on input; block dropped".into(),
723 }),
724 ContentPart::ToolUse { .. } | ContentPart::ToolResult { .. } => {
725 warnings.push(ModelWarning::LossyEncode {
726 field: path(),
727 detail: "tool_use / tool_result not allowed on user role for Bedrock Converse"
728 .into(),
729 });
730 }
731 ContentPart::ImageOutput { .. } | ContentPart::AudioOutput { .. } => {
732 warnings.push(ModelWarning::LossyEncode {
733 field: path(),
734 detail: "Bedrock Converse does not accept assistant-produced \
735 image / audio output as input — block dropped"
736 .into(),
737 });
738 }
739 ContentPart::RedactedThinking { .. } => {
740 warnings.push(ModelWarning::LossyEncode {
741 field: path(),
742 detail: "Bedrock Converse does not accept redacted_thinking blocks on \
743 user-role input; block dropped"
744 .into(),
745 });
746 }
747 }
748 attach_cache_point(&mut out, cache, path, warnings);
749 }
750 out
751}
752
753fn encode_assistant_content(
754 parts: &[ContentPart],
755 warnings: &mut Vec<ModelWarning>,
756 msg_idx: usize,
757) -> Vec<Value> {
758 let mut out = Vec::new();
759 for (part_idx, part) in parts.iter().enumerate() {
760 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
761 let cache = content_part_cache_control(part);
762 match part {
763 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
764 ContentPart::ToolUse {
765 id, name, input, ..
766 } => {
767 out.push(json!({
768 "toolUse": {
769 "toolUseId": id,
770 "name": name,
771 "input": input,
772 },
773 }));
774 }
775 ContentPart::Thinking {
776 text,
777 provider_echoes,
778 ..
779 } => {
780 let mut inner = Map::new();
781 inner.insert("text".into(), Value::String(text.clone()));
782 if let Some(sig) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
783 .and_then(|snap| snap.payload_str("signature"))
784 {
785 inner.insert("signature".into(), Value::String(sig.to_owned()));
786 }
787 let mut reasoning = Map::new();
788 reasoning.insert("reasoningText".into(), Value::Object(inner));
789 if let Some(redacted) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
790 .and_then(|e| e.payload_str("redacted_content"))
791 {
792 reasoning.insert("redactedContent".into(), Value::String(redacted.to_owned()));
793 }
794 out.push(json!({ "reasoningContent": Value::Object(reasoning) }));
795 }
796 ContentPart::RedactedThinking { provider_echoes } => {
797 let Some(redacted) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
798 .and_then(|e| e.payload_str("redacted_content"))
799 else {
800 warnings.push(ModelWarning::LossyEncode {
801 field: path(),
802 detail: "redacted_thinking part missing 'bedrock-converse' \
803 provider_echo with 'redacted_content' payload; block dropped"
804 .into(),
805 });
806 continue;
807 };
808 out.push(json!({
809 "reasoningContent": {
810 "redactedContent": redacted,
811 }
812 }));
813 }
814 ContentPart::Citation { snippet, .. } => out.push(json!({ "text": snippet })),
815 other => {
816 warnings.push(ModelWarning::LossyEncode {
817 field: path(),
818 detail: format!(
819 "{} not supported on assistant role for Bedrock Converse — dropped",
820 debug_part_kind(other)
821 ),
822 });
823 }
824 }
825 attach_cache_point(&mut out, cache, path, warnings);
826 }
827 out
828}
829
830fn encode_tool_results(
831 parts: &[ContentPart],
832 warnings: &mut Vec<ModelWarning>,
833 msg_idx: usize,
834) -> Vec<Value> {
835 let mut out = Vec::new();
836 for (part_idx, part) in parts.iter().enumerate() {
837 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
838 let cache = content_part_cache_control(part);
839 if let ContentPart::ToolResult {
840 tool_use_id,
841 content,
842 is_error,
843 ..
844 } = part
845 {
846 let inner = match content {
847 ToolResultContent::Text(t) => json!([{ "text": t }]),
848 ToolResultContent::Json(v) => json!([{ "json": v }]),
849 };
850 out.push(json!({
851 "toolResult": {
852 "toolUseId": tool_use_id,
853 "content": inner,
854 "status": if *is_error { "error" } else { "success" },
855 },
856 }));
857 } else {
858 warnings.push(ModelWarning::LossyEncode {
859 field: path(),
860 detail: "non-tool_result part on Role::Tool dropped".into(),
861 });
862 }
863 attach_cache_point(&mut out, cache, path, warnings);
864 }
865 out
866}
867
868const fn content_part_cache_control(part: &ContentPart) -> Option<crate::ir::CacheControl> {
875 match part {
876 ContentPart::Text { cache_control, .. }
877 | ContentPart::Image { cache_control, .. }
878 | ContentPart::Audio { cache_control, .. }
879 | ContentPart::Video { cache_control, .. }
880 | ContentPart::Document { cache_control, .. }
881 | ContentPart::Thinking { cache_control, .. }
882 | ContentPart::Citation { cache_control, .. }
883 | ContentPart::ToolResult { cache_control, .. } => *cache_control,
884 ContentPart::ToolUse { .. }
885 | ContentPart::ImageOutput { .. }
886 | ContentPart::AudioOutput { .. }
887 | ContentPart::RedactedThinking { .. } => None,
888 }
889}
890
891fn encode_tools(tools: &[crate::ir::ToolSpec], warnings: &mut Vec<ModelWarning>) -> Value {
892 let mut arr: Vec<Value> = Vec::with_capacity(tools.len());
900 for (idx, t) in tools.iter().enumerate() {
901 let ToolKind::Function { input_schema } = &t.kind else {
902 warnings.push(ModelWarning::LossyEncode {
903 field: format!("tools[{idx}]"),
904 detail: "Bedrock Converse `toolConfig` advertises only function tools — \
905 vendor built-ins (web_search, computer, text_editor, …) ride the \
906 underlying model's native surface and are not bridged here; \
907 tool dropped"
908 .into(),
909 });
910 continue;
911 };
912 arr.push(json!({
913 "toolSpec": {
914 "name": t.name,
915 "description": t.description,
916 "inputSchema": { "json": input_schema.clone() },
917 },
918 }));
919 attach_cache_point(
920 &mut arr,
921 t.cache_control,
922 || format!("tools[{idx}]"),
923 warnings,
924 );
925 }
926 Value::Array(arr)
927}
928
929fn attach_cache_point(
937 out: &mut Vec<Value>,
938 cache: Option<crate::ir::CacheControl>,
939 field: impl FnOnce() -> String,
940 warnings: &mut Vec<ModelWarning>,
941) {
942 let Some(cache) = cache else {
943 return;
944 };
945 if cache.ttl != crate::ir::CacheTtl::FiveMinutes {
946 warnings.push(ModelWarning::LossyEncode {
947 field: format!("{}.cache_control.ttl", field()),
948 detail: format!(
949 "Bedrock cachePoint has no TTL knob — IR ttl `{:?}` coerced to vendor default",
950 cache.ttl
951 ),
952 });
953 }
954 out.push(json!({ "cachePoint": { "type": "default" } }));
955}
956
957fn encode_tool_choice(choice: &ToolChoice) -> Value {
958 match choice {
959 ToolChoice::Auto | ToolChoice::None => json!({ "auto": {} }),
962 ToolChoice::Required => json!({ "any": {} }),
963 ToolChoice::Specific { name } => json!({ "tool": { "name": name } }),
964 }
965}
966
967const fn debug_part_kind(part: &ContentPart) -> &'static str {
968 match part {
969 ContentPart::Text { .. } => "text",
970 ContentPart::Image { .. } => "image",
971 ContentPart::Audio { .. } => "audio",
972 ContentPart::Video { .. } => "video",
973 ContentPart::Document { .. } => "document",
974 ContentPart::Thinking { .. } => "thinking",
975 ContentPart::Citation { .. } => "citation",
976 ContentPart::ToolUse { .. } => "tool_use",
977 ContentPart::ToolResult { .. } => "tool_result",
978 ContentPart::ImageOutput { .. } => "image_output",
979 ContentPart::AudioOutput { .. } => "audio_output",
980 ContentPart::RedactedThinking { .. } => "redacted_thinking",
981 }
982}
983
984fn decode_output(raw: &Value, warnings: &mut Vec<ModelWarning>) -> (Vec<ContentPart>, StopReason) {
987 let message = raw
988 .get("output")
989 .and_then(|o| o.get("message"))
990 .cloned()
991 .unwrap_or(Value::Null); let parts_raw = message
993 .get("content")
994 .and_then(Value::as_array)
995 .cloned()
996 .unwrap_or_default(); let mut parts = Vec::new();
998 for (idx, part) in parts_raw.iter().enumerate() {
999 if let Some(text) = part.get("text").and_then(Value::as_str)
1000 && !text.is_empty()
1001 {
1002 parts.push(ContentPart::text(text));
1003 continue;
1004 }
1005 if let Some(reasoning) = part.get("reasoningContent") {
1006 let text = reasoning
1007 .get("reasoningText")
1008 .and_then(|t| t.get("text"))
1009 .and_then(Value::as_str)
1010 .unwrap_or("") .to_owned();
1012 let signature = reasoning
1013 .get("reasoningText")
1014 .and_then(|t| t.get("signature"))
1015 .and_then(Value::as_str)
1016 .map(str::to_owned);
1017 let redacted = reasoning
1018 .get("redactedContent")
1019 .and_then(Value::as_str)
1020 .map(str::to_owned);
1021 let mut payload = Map::new();
1022 if let Some(s) = &signature {
1023 payload.insert("signature".into(), Value::String(s.clone()));
1024 }
1025 if let Some(r) = &redacted {
1026 payload.insert("redacted_content".into(), Value::String(r.clone()));
1027 }
1028 let provider_echoes = if payload.is_empty() {
1029 Vec::new()
1030 } else {
1031 vec![ProviderEchoSnapshot::new(
1032 PROVIDER_KEY,
1033 Value::Object(payload),
1034 )]
1035 };
1036 if text.is_empty() && signature.is_none() && redacted.is_some() {
1037 parts.push(ContentPart::RedactedThinking { provider_echoes });
1038 } else if !text.is_empty() || !provider_echoes.is_empty() {
1039 parts.push(ContentPart::Thinking {
1040 text,
1041 cache_control: None,
1042 provider_echoes,
1043 });
1044 }
1045 continue;
1046 }
1047 if let Some(tool_use) = part.get("toolUse") {
1048 let id = str_field(tool_use, "toolUseId").to_owned();
1049 let name = str_field(tool_use, "name").to_owned();
1050 let input = tool_use.get("input").cloned().unwrap_or_else(|| json!({})); parts.push(ContentPart::ToolUse {
1052 id,
1053 name,
1054 input,
1055 provider_echoes: Vec::new(),
1056 });
1057 continue;
1058 }
1059 warnings.push(ModelWarning::LossyEncode {
1060 field: format!("output.message.content[{idx}]"),
1061 detail: "unknown Bedrock content block type dropped".into(),
1062 });
1063 }
1064 let stop_reason = decode_stop_reason(raw, warnings);
1065 (parts, stop_reason)
1066}
1067
1068fn decode_stop_reason(raw: &Value, warnings: &mut Vec<ModelWarning>) -> StopReason {
1069 let reason = raw.get("stopReason").and_then(Value::as_str);
1070 match reason {
1071 Some("end_turn") => StopReason::EndTurn,
1072 Some("max_tokens") => StopReason::MaxTokens,
1073 Some("stop_sequence") => {
1080 let matched = raw
1081 .get("additionalModelResponseFields")
1082 .and_then(|f| f.get("stop_sequence"))
1083 .and_then(Value::as_str);
1084 match matched {
1085 Some(s) if !s.is_empty() => StopReason::StopSequence {
1086 sequence: s.to_owned(),
1087 },
1088 _ => {
1089 warnings.push(ModelWarning::LossyEncode {
1090 field: "stop_sequence".into(),
1091 detail: "Bedrock Converse signalled `stop_sequence` but the matched \
1092 string is not exposed on the wire — IR records \
1093 `Other{raw:\"stop_sequence\"}`"
1094 .into(),
1095 });
1096 StopReason::Other {
1097 raw: "stop_sequence".to_owned(),
1098 }
1099 }
1100 }
1101 }
1102 Some("tool_use") => StopReason::ToolUse,
1103 Some("guardrail_intervened" | "content_filtered") => StopReason::Refusal {
1104 reason: RefusalReason::Guardrail,
1105 },
1106 Some(
1111 raw @ ("malformed_model_output"
1112 | "malformed_tool_use"
1113 | "model_context_window_exceeded"),
1114 ) => StopReason::Other {
1115 raw: raw.to_owned(),
1116 },
1117 Some(other) => {
1118 warnings.push(ModelWarning::UnknownStopReason {
1119 raw: other.to_owned(),
1120 });
1121 StopReason::Other {
1122 raw: other.to_owned(),
1123 }
1124 }
1125 None => {
1126 warnings.push(ModelWarning::LossyEncode {
1130 field: "stopReason".into(),
1131 detail: "Bedrock Converse response carried no stopReason — \
1132 IR records `Other{raw:\"missing\"}`"
1133 .into(),
1134 });
1135 StopReason::Other {
1136 raw: "missing".to_owned(),
1137 }
1138 }
1139 }
1140}
1141
1142fn decode_usage(usage: Option<&Value>) -> Usage {
1143 Usage {
1144 input_tokens: u_field(usage, "inputTokens"),
1145 output_tokens: u_field(usage, "outputTokens"),
1146 cached_input_tokens: u_field(usage, "cacheReadInputTokens"),
1147 cache_creation_input_tokens: u_field(usage, "cacheWriteInputTokens"),
1148 reasoning_tokens: 0,
1149 safety_ratings: Vec::new(),
1150 }
1151}
1152
1153fn str_field<'a>(v: &'a Value, key: &str) -> &'a str {
1154 v.get(key).and_then(Value::as_str).unwrap_or("") }
1156
1157fn u_field(v: Option<&Value>, key: &str) -> u32 {
1158 v.and_then(|inner| inner.get(key))
1159 .and_then(Value::as_u64)
1160 .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)) }