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(); match strategy {
296 OutputStrategy::Native => {
297 additional.insert(
298 "output_config".into(),
299 json!({
300 "format": {
301 "type": "json_schema",
302 "schema": format.json_schema.schema.clone(),
303 }
304 }),
305 );
306 if !format.strict {
307 warnings.push(ModelWarning::LossyEncode {
308 field: "response_format.strict".into(),
309 detail: "Anthropic-on-Bedrock always strict-validates structured output; \
310 the strict=false request was approximated"
311 .into(),
312 });
313 }
314 }
315 OutputStrategy::Tool => {
316 let tool_name = format.json_schema.name.clone();
324 additional.insert(
325 "tools".into(),
326 json!([{
327 "type": "custom",
328 "name": tool_name,
329 "description": format!(
330 "Emit the response as a JSON object matching the {tool_name} schema."
331 ),
332 "input_schema": format.json_schema.schema.clone(),
333 }]),
334 );
335 additional.insert(
336 "tool_choice".into(),
337 json!({
338 "type": "tool",
339 "name": format.json_schema.name,
340 "disable_parallel_tool_use": true,
341 }),
342 );
343 if !format.strict {
344 warnings.push(ModelWarning::LossyEncode {
345 field: "response_format.strict".into(),
346 detail: "Bedrock-Anthropic Tool-strategy structured output is always \
347 schema-validated; strict=false was approximated"
348 .into(),
349 });
350 }
351 }
352 OutputStrategy::Prompted => {
353 return Err(Error::invalid_request(
354 "OutputStrategy::Prompted is deferred to entelix 1.1; use \
355 OutputStrategy::Native or OutputStrategy::Tool",
356 ));
357 }
358 OutputStrategy::Auto => unreachable!("Auto resolved above"),
359 }
360 body.insert(
361 "additionalModelRequestFields".into(),
362 Value::Object(additional),
363 );
364 Ok(())
365}
366
367fn is_bedrock_anthropic(model: &str) -> bool {
373 model.contains("anthropic.claude-")
377}
378
379fn is_bedrock_anthropic_adaptive_only(model: &str) -> bool {
382 is_bedrock_anthropic(model) && model.contains("claude-opus-4-7")
383}
384
385fn encode_bedrock_thinking(
391 model: &str,
392 effort: &ReasoningEffort,
393 body: &mut Map<String, Value>,
394 warnings: &mut Vec<ModelWarning>,
395) {
396 if !is_bedrock_anthropic(model) {
397 warnings.push(ModelWarning::LossyEncode {
398 field: "reasoning_effort".into(),
399 detail: format!(
400 "Bedrock model {model:?} is not in the Anthropic family — Bedrock has no \
401 thinking knob for non-Anthropic models; field dropped"
402 ),
403 });
404 return;
405 }
406 let adaptive_only = is_bedrock_anthropic_adaptive_only(model);
407 let thinking = match effort {
408 ReasoningEffort::Off => json!({"type": "disabled"}),
409 ReasoningEffort::Minimal => {
410 warnings.push(ModelWarning::LossyEncode {
411 field: "reasoning_effort".into(),
412 detail: "Anthropic on Bedrock has no `Minimal` bucket — snapped to adaptive `low`"
413 .into(),
414 });
415 json!({"type": "adaptive", "effort": "low"})
416 }
417 ReasoningEffort::Low => {
418 if adaptive_only {
419 json!({"type": "adaptive", "effort": "low"})
420 } else {
421 json!({"type": "enabled", "budget_tokens": 1024})
422 }
423 }
424 ReasoningEffort::Medium => {
425 if adaptive_only {
426 json!({"type": "adaptive", "effort": "medium"})
427 } else {
428 json!({"type": "enabled", "budget_tokens": 4096})
429 }
430 }
431 ReasoningEffort::High => {
432 if adaptive_only {
433 json!({"type": "adaptive", "effort": "high"})
434 } else {
435 json!({"type": "enabled", "budget_tokens": 16384})
436 }
437 }
438 ReasoningEffort::Auto => json!({"type": "adaptive"}),
439 ReasoningEffort::VendorSpecific(literal) => {
440 if adaptive_only {
441 warnings.push(ModelWarning::LossyEncode {
442 field: "reasoning_effort".into(),
443 detail: format!(
444 "Bedrock-Anthropic {model} is adaptive-only — manual budget \
445 {literal:?} dropped; emitting `{{type:\"adaptive\"}}` instead"
446 ),
447 });
448 json!({"type": "adaptive"})
449 } else if let Ok(budget) = literal.parse::<u32>() {
450 json!({"type": "enabled", "budget_tokens": budget})
451 } else {
452 warnings.push(ModelWarning::LossyEncode {
453 field: "reasoning_effort".into(),
454 detail: format!(
455 "Bedrock-Anthropic vendor-specific reasoning_effort {literal:?} is not \
456 a numeric budget_tokens — falling through to `Medium`"
457 ),
458 });
459 json!({"type": "enabled", "budget_tokens": 4096})
460 }
461 }
462 };
463 let mut additional = body
464 .remove("additionalModelRequestFields")
465 .and_then(|v| match v {
466 Value::Object(o) => Some(o),
467 _ => None,
468 })
469 .unwrap_or_default(); additional.insert("thinking".into(), thinking);
471 body.insert(
472 "additionalModelRequestFields".into(),
473 Value::Object(additional),
474 );
475}
476
477fn apply_provider_extensions(
482 request: &ModelRequest,
483 body: &mut Map<String, Value>,
484 warnings: &mut Vec<ModelWarning>,
485) {
486 let ext = &request.provider_extensions;
487 if let Some(bedrock) = &ext.bedrock {
488 if let Some(guardrail) = &bedrock.guardrail {
489 body.insert(
490 "guardrailConfig".into(),
491 json!({
492 "guardrailIdentifier": guardrail.identifier,
493 "guardrailVersion": guardrail.version,
494 }),
495 );
496 }
497 if let Some(tier) = &bedrock.performance_config_tier {
498 body.insert("performanceConfig".into(), json!({ "latency": tier }));
499 }
500 }
501 if request.parallel_tool_calls.is_some() {
506 warnings.push(ModelWarning::LossyEncode {
507 field: "parallel_tool_calls".into(),
508 detail: "Bedrock Converse exposes no equivalent toggle — \
509 setting dropped on the wire"
510 .into(),
511 });
512 }
513 if let Some(user_id) = &request.end_user_id {
514 if is_bedrock_anthropic(&request.model) {
515 let entry = body
520 .entry("additionalModelRequestFields")
521 .or_insert_with(|| Value::Object(Map::new()));
522 if let Some(map) = entry.as_object_mut() {
523 let metadata = map
524 .entry("metadata")
525 .or_insert_with(|| Value::Object(Map::new()));
526 if let Some(meta_map) = metadata.as_object_mut() {
527 meta_map.insert("user_id".into(), Value::String(user_id.clone()));
528 }
529 }
530 } else {
531 warnings.push(ModelWarning::LossyEncode {
532 field: "end_user_id".into(),
533 detail: "Bedrock Converse non-Anthropic models have no per-request end-user \
534 attribution channel — setting dropped"
535 .into(),
536 });
537 }
538 }
539 if request.seed.is_some() {
540 warnings.push(ModelWarning::LossyEncode {
541 field: "seed".into(),
542 detail: "Bedrock Converse has no deterministic-sampling knob — setting dropped".into(),
543 });
544 }
545 if ext.openai_chat.is_some() {
546 warnings.push(ModelWarning::ProviderExtensionIgnored {
547 vendor: "openai_chat".into(),
548 });
549 }
550 if ext.openai_responses.is_some() {
551 warnings.push(ModelWarning::ProviderExtensionIgnored {
552 vendor: "openai_responses".into(),
553 });
554 }
555 if ext.gemini.is_some() {
556 warnings.push(ModelWarning::ProviderExtensionIgnored {
557 vendor: "gemini".into(),
558 });
559 }
560}
561
562fn finalize_request(
563 model: &str,
564 body: &Value,
565 warnings: Vec<ModelWarning>,
566 streaming: bool,
567) -> Result<EncodedRequest> {
568 let bytes = serde_json::to_vec(body)?;
569 let path = if streaming {
570 format!("/model/{model}/converse-stream")
571 } else {
572 format!("/model/{model}/converse")
573 };
574 let mut encoded = EncodedRequest::post_json(path, Bytes::from(bytes));
575 encoded.warnings = warnings;
576 Ok(encoded)
577}
578
579fn encode_messages(
582 request: &ModelRequest,
583 warnings: &mut Vec<ModelWarning>,
584) -> (Vec<Value>, Vec<Value>) {
585 let mut system_blocks: Vec<Value> = Vec::new();
591 for (idx, block) in request.system.blocks().iter().enumerate() {
592 system_blocks.push(json!({ "text": block.text.clone() }));
593 attach_cache_point(
594 &mut system_blocks,
595 block.cache_control,
596 || format!("system[{idx}]"),
597 warnings,
598 );
599 }
600 let mut messages = Vec::new();
601
602 for (idx, msg) in request.messages.iter().enumerate() {
603 match msg.role {
604 Role::System => {
605 let mut text = String::new();
606 let mut lossy = false;
607 for part in &msg.content {
608 if let ContentPart::Text { text: t, .. } = part {
609 text.push_str(t);
610 } else {
611 lossy = true;
612 }
613 }
614 if lossy {
615 warnings.push(ModelWarning::LossyEncode {
616 field: format!("messages[{idx}].content"),
617 detail: "non-text parts dropped from system message (Bedrock routes \
618 system into top-level system array)"
619 .into(),
620 });
621 }
622 if !text.is_empty() {
623 system_blocks.push(json!({ "text": text }));
624 }
625 }
626 Role::User => {
627 messages.push(json!({
628 "role": "user",
629 "content": encode_user_content(&msg.content, warnings, idx),
630 }));
631 }
632 Role::Assistant => {
633 messages.push(json!({
634 "role": "assistant",
635 "content": encode_assistant_content(&msg.content, warnings, idx),
636 }));
637 }
638 Role::Tool => {
639 messages.push(json!({
640 "role": "user",
641 "content": encode_tool_results(&msg.content, warnings, idx),
642 }));
643 }
644 }
645 }
646 (system_blocks, messages)
647}
648
649fn encode_user_content(
650 parts: &[ContentPart],
651 warnings: &mut Vec<ModelWarning>,
652 msg_idx: usize,
653) -> Vec<Value> {
654 let mut out = Vec::new();
655 for (part_idx, part) in parts.iter().enumerate() {
656 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
657 let cache = content_part_cache_control(part);
658 match part {
659 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
660 ContentPart::Image { source, .. } => match source {
661 MediaSource::Base64 { media_type, data } => {
662 let format_str = media_type.split('/').next_back().unwrap_or("png"); out.push(json!({
664 "image": {
665 "format": format_str,
666 "source": { "bytes": data },
667 },
668 }));
669 }
670 MediaSource::Url { url, .. } => warnings.push(ModelWarning::LossyEncode {
671 field: path(),
672 detail: format!(
673 "Bedrock Converse requires base64 inline image bytes; URL '{url}' dropped"
674 ),
675 }),
676 MediaSource::FileId { .. } => warnings.push(ModelWarning::LossyEncode {
677 field: path(),
678 detail: "Bedrock Converse does not accept FileId image input".into(),
679 }),
680 },
681 ContentPart::Audio { .. } => warnings.push(ModelWarning::LossyEncode {
682 field: path(),
683 detail: "Bedrock Converse does not accept audio inputs; block dropped".into(),
684 }),
685 ContentPart::Video { .. } => warnings.push(ModelWarning::LossyEncode {
686 field: path(),
687 detail: "Bedrock Converse video input is not declared in the codec's default \
688 capability set (Nova-series models only); block dropped"
689 .into(),
690 }),
691 ContentPart::Document { source, name, .. } => match source {
692 MediaSource::Base64 { media_type, data } => {
693 let format_str = media_type.split('/').next_back().unwrap_or("pdf"); let mut inner = Map::new();
695 inner.insert("format".into(), Value::String(format_str.into()));
696 if let Some(n) = name {
697 inner.insert("name".into(), Value::String(n.clone()));
698 }
699 inner.insert("source".into(), json!({ "bytes": data }));
700 out.push(json!({ "document": Value::Object(inner) }));
701 }
702 _ => warnings.push(ModelWarning::LossyEncode {
703 field: path(),
704 detail: "Bedrock Converse document accepts only base64 inline; URL/FileId \
705 dropped"
706 .into(),
707 }),
708 },
709 ContentPart::Thinking { .. } => warnings.push(ModelWarning::LossyEncode {
710 field: path(),
711 detail: "Bedrock Converse does not accept thinking blocks on input; block dropped"
712 .into(),
713 }),
714 ContentPart::Citation { .. } => warnings.push(ModelWarning::LossyEncode {
715 field: path(),
716 detail: "Bedrock Converse does not echo citations on input; block dropped".into(),
717 }),
718 ContentPart::ToolUse { .. } | ContentPart::ToolResult { .. } => {
719 warnings.push(ModelWarning::LossyEncode {
720 field: path(),
721 detail: "tool_use / tool_result not allowed on user role for Bedrock Converse"
722 .into(),
723 });
724 }
725 ContentPart::ImageOutput { .. } | ContentPart::AudioOutput { .. } => {
726 warnings.push(ModelWarning::LossyEncode {
727 field: path(),
728 detail: "Bedrock Converse does not accept assistant-produced \
729 image / audio output as input — block dropped"
730 .into(),
731 });
732 }
733 ContentPart::RedactedThinking { .. } => {
734 warnings.push(ModelWarning::LossyEncode {
735 field: path(),
736 detail: "Bedrock Converse does not accept redacted_thinking blocks on \
737 user-role input; block dropped"
738 .into(),
739 });
740 }
741 }
742 attach_cache_point(&mut out, cache, path, warnings);
743 }
744 out
745}
746
747fn encode_assistant_content(
748 parts: &[ContentPart],
749 warnings: &mut Vec<ModelWarning>,
750 msg_idx: usize,
751) -> Vec<Value> {
752 let mut out = Vec::new();
753 for (part_idx, part) in parts.iter().enumerate() {
754 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
755 let cache = content_part_cache_control(part);
756 match part {
757 ContentPart::Text { text, .. } => out.push(json!({ "text": text })),
758 ContentPart::ToolUse {
759 id, name, input, ..
760 } => {
761 out.push(json!({
762 "toolUse": {
763 "toolUseId": id,
764 "name": name,
765 "input": input,
766 },
767 }));
768 }
769 ContentPart::Thinking {
770 text,
771 provider_echoes,
772 ..
773 } => {
774 let mut inner = Map::new();
775 inner.insert("text".into(), Value::String(text.clone()));
776 if let Some(sig) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
777 .and_then(|snap| snap.payload_str("signature"))
778 {
779 inner.insert("signature".into(), Value::String(sig.to_owned()));
780 }
781 let mut reasoning = Map::new();
782 reasoning.insert("reasoningText".into(), Value::Object(inner));
783 if let Some(redacted) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
784 .and_then(|e| e.payload_str("redacted_content"))
785 {
786 reasoning.insert("redactedContent".into(), Value::String(redacted.to_owned()));
787 }
788 out.push(json!({ "reasoningContent": Value::Object(reasoning) }));
789 }
790 ContentPart::RedactedThinking { provider_echoes } => {
791 let Some(redacted) = ProviderEchoSnapshot::find_in(provider_echoes, PROVIDER_KEY)
792 .and_then(|e| e.payload_str("redacted_content"))
793 else {
794 warnings.push(ModelWarning::LossyEncode {
795 field: path(),
796 detail: "redacted_thinking part missing 'bedrock-converse' \
797 provider_echo with 'redacted_content' payload; block dropped"
798 .into(),
799 });
800 continue;
801 };
802 out.push(json!({
803 "reasoningContent": {
804 "redactedContent": redacted,
805 }
806 }));
807 }
808 ContentPart::Citation { snippet, .. } => out.push(json!({ "text": snippet })),
809 other => {
810 warnings.push(ModelWarning::LossyEncode {
811 field: path(),
812 detail: format!(
813 "{} not supported on assistant role for Bedrock Converse — dropped",
814 debug_part_kind(other)
815 ),
816 });
817 }
818 }
819 attach_cache_point(&mut out, cache, path, warnings);
820 }
821 out
822}
823
824fn encode_tool_results(
825 parts: &[ContentPart],
826 warnings: &mut Vec<ModelWarning>,
827 msg_idx: usize,
828) -> Vec<Value> {
829 let mut out = Vec::new();
830 for (part_idx, part) in parts.iter().enumerate() {
831 let path = || format!("messages[{msg_idx}].content[{part_idx}]");
832 let cache = content_part_cache_control(part);
833 if let ContentPart::ToolResult {
834 tool_use_id,
835 content,
836 is_error,
837 ..
838 } = part
839 {
840 let inner = match content {
841 ToolResultContent::Text(t) => json!([{ "text": t }]),
842 ToolResultContent::Json(v) => json!([{ "json": v }]),
843 };
844 out.push(json!({
845 "toolResult": {
846 "toolUseId": tool_use_id,
847 "content": inner,
848 "status": if *is_error { "error" } else { "success" },
849 },
850 }));
851 } else {
852 warnings.push(ModelWarning::LossyEncode {
853 field: path(),
854 detail: "non-tool_result part on Role::Tool dropped".into(),
855 });
856 }
857 attach_cache_point(&mut out, cache, path, warnings);
858 }
859 out
860}
861
862const fn content_part_cache_control(part: &ContentPart) -> Option<crate::ir::CacheControl> {
869 match part {
870 ContentPart::Text { cache_control, .. }
871 | ContentPart::Image { cache_control, .. }
872 | ContentPart::Audio { cache_control, .. }
873 | ContentPart::Video { cache_control, .. }
874 | ContentPart::Document { cache_control, .. }
875 | ContentPart::Thinking { cache_control, .. }
876 | ContentPart::Citation { cache_control, .. }
877 | ContentPart::ToolResult { cache_control, .. } => *cache_control,
878 ContentPart::ToolUse { .. }
879 | ContentPart::ImageOutput { .. }
880 | ContentPart::AudioOutput { .. }
881 | ContentPart::RedactedThinking { .. } => None,
882 }
883}
884
885fn encode_tools(tools: &[crate::ir::ToolSpec], warnings: &mut Vec<ModelWarning>) -> Value {
886 let mut arr: Vec<Value> = Vec::with_capacity(tools.len());
894 for (idx, t) in tools.iter().enumerate() {
895 let ToolKind::Function { input_schema } = &t.kind else {
896 warnings.push(ModelWarning::LossyEncode {
897 field: format!("tools[{idx}]"),
898 detail: "Bedrock Converse `toolConfig` advertises only function tools — \
899 vendor built-ins (web_search, computer, text_editor, …) ride the \
900 underlying model's native surface and are not bridged here; \
901 tool dropped"
902 .into(),
903 });
904 continue;
905 };
906 arr.push(json!({
907 "toolSpec": {
908 "name": t.name,
909 "description": t.description,
910 "inputSchema": { "json": input_schema.clone() },
911 },
912 }));
913 attach_cache_point(
914 &mut arr,
915 t.cache_control,
916 || format!("tools[{idx}]"),
917 warnings,
918 );
919 }
920 Value::Array(arr)
921}
922
923fn attach_cache_point(
931 out: &mut Vec<Value>,
932 cache: Option<crate::ir::CacheControl>,
933 field: impl FnOnce() -> String,
934 warnings: &mut Vec<ModelWarning>,
935) {
936 let Some(cache) = cache else {
937 return;
938 };
939 if cache.ttl != crate::ir::CacheTtl::FiveMinutes {
940 warnings.push(ModelWarning::LossyEncode {
941 field: format!("{}.cache_control.ttl", field()),
942 detail: format!(
943 "Bedrock cachePoint has no TTL knob — IR ttl `{:?}` coerced to vendor default",
944 cache.ttl
945 ),
946 });
947 }
948 out.push(json!({ "cachePoint": { "type": "default" } }));
949}
950
951fn encode_tool_choice(choice: &ToolChoice) -> Value {
952 match choice {
953 ToolChoice::Auto | ToolChoice::None => json!({ "auto": {} }),
956 ToolChoice::Required => json!({ "any": {} }),
957 ToolChoice::Specific { name } => json!({ "tool": { "name": name } }),
958 }
959}
960
961const fn debug_part_kind(part: &ContentPart) -> &'static str {
962 match part {
963 ContentPart::Text { .. } => "text",
964 ContentPart::Image { .. } => "image",
965 ContentPart::Audio { .. } => "audio",
966 ContentPart::Video { .. } => "video",
967 ContentPart::Document { .. } => "document",
968 ContentPart::Thinking { .. } => "thinking",
969 ContentPart::Citation { .. } => "citation",
970 ContentPart::ToolUse { .. } => "tool_use",
971 ContentPart::ToolResult { .. } => "tool_result",
972 ContentPart::ImageOutput { .. } => "image_output",
973 ContentPart::AudioOutput { .. } => "audio_output",
974 ContentPart::RedactedThinking { .. } => "redacted_thinking",
975 }
976}
977
978fn decode_output(raw: &Value, warnings: &mut Vec<ModelWarning>) -> (Vec<ContentPart>, StopReason) {
981 let message = raw
982 .get("output")
983 .and_then(|o| o.get("message"))
984 .cloned()
985 .unwrap_or(Value::Null); let parts_raw = message
987 .get("content")
988 .and_then(Value::as_array)
989 .cloned()
990 .unwrap_or_default(); let mut parts = Vec::new();
992 for (idx, part) in parts_raw.iter().enumerate() {
993 if let Some(text) = part.get("text").and_then(Value::as_str)
994 && !text.is_empty()
995 {
996 parts.push(ContentPart::text(text));
997 continue;
998 }
999 if let Some(reasoning) = part.get("reasoningContent") {
1000 let text = reasoning
1001 .get("reasoningText")
1002 .and_then(|t| t.get("text"))
1003 .and_then(Value::as_str)
1004 .unwrap_or("") .to_owned();
1006 let signature = reasoning
1007 .get("reasoningText")
1008 .and_then(|t| t.get("signature"))
1009 .and_then(Value::as_str)
1010 .map(str::to_owned);
1011 let redacted = reasoning
1012 .get("redactedContent")
1013 .and_then(Value::as_str)
1014 .map(str::to_owned);
1015 let mut payload = Map::new();
1016 if let Some(s) = &signature {
1017 payload.insert("signature".into(), Value::String(s.clone()));
1018 }
1019 if let Some(r) = &redacted {
1020 payload.insert("redacted_content".into(), Value::String(r.clone()));
1021 }
1022 let provider_echoes = if payload.is_empty() {
1023 Vec::new()
1024 } else {
1025 vec![ProviderEchoSnapshot::new(
1026 PROVIDER_KEY,
1027 Value::Object(payload),
1028 )]
1029 };
1030 if text.is_empty() && signature.is_none() && redacted.is_some() {
1031 parts.push(ContentPart::RedactedThinking { provider_echoes });
1032 } else if !text.is_empty() || !provider_echoes.is_empty() {
1033 parts.push(ContentPart::Thinking {
1034 text,
1035 cache_control: None,
1036 provider_echoes,
1037 });
1038 }
1039 continue;
1040 }
1041 if let Some(tool_use) = part.get("toolUse") {
1042 let id = str_field(tool_use, "toolUseId").to_owned();
1043 let name = str_field(tool_use, "name").to_owned();
1044 let input = tool_use.get("input").cloned().unwrap_or_else(|| json!({})); parts.push(ContentPart::ToolUse {
1046 id,
1047 name,
1048 input,
1049 provider_echoes: Vec::new(),
1050 });
1051 continue;
1052 }
1053 warnings.push(ModelWarning::LossyEncode {
1054 field: format!("output.message.content[{idx}]"),
1055 detail: "unknown Bedrock content block type dropped".into(),
1056 });
1057 }
1058 let stop_reason = decode_stop_reason(raw, warnings);
1059 (parts, stop_reason)
1060}
1061
1062fn decode_stop_reason(raw: &Value, warnings: &mut Vec<ModelWarning>) -> StopReason {
1063 let reason = raw.get("stopReason").and_then(Value::as_str);
1064 match reason {
1065 Some("end_turn") => StopReason::EndTurn,
1066 Some("max_tokens") => StopReason::MaxTokens,
1067 Some("stop_sequence") => {
1074 let matched = raw
1075 .get("additionalModelResponseFields")
1076 .and_then(|f| f.get("stop_sequence"))
1077 .and_then(Value::as_str);
1078 match matched {
1079 Some(s) if !s.is_empty() => StopReason::StopSequence {
1080 sequence: s.to_owned(),
1081 },
1082 _ => {
1083 warnings.push(ModelWarning::LossyEncode {
1084 field: "stop_sequence".into(),
1085 detail: "Bedrock Converse signalled `stop_sequence` but the matched \
1086 string is not exposed on the wire — IR records \
1087 `Other{raw:\"stop_sequence\"}`"
1088 .into(),
1089 });
1090 StopReason::Other {
1091 raw: "stop_sequence".to_owned(),
1092 }
1093 }
1094 }
1095 }
1096 Some("tool_use") => StopReason::ToolUse,
1097 Some("guardrail_intervened" | "content_filtered") => StopReason::Refusal {
1098 reason: RefusalReason::Guardrail,
1099 },
1100 Some(
1105 raw @ ("malformed_model_output"
1106 | "malformed_tool_use"
1107 | "model_context_window_exceeded"),
1108 ) => StopReason::Other {
1109 raw: raw.to_owned(),
1110 },
1111 Some(other) => {
1112 warnings.push(ModelWarning::UnknownStopReason {
1113 raw: other.to_owned(),
1114 });
1115 StopReason::Other {
1116 raw: other.to_owned(),
1117 }
1118 }
1119 None => {
1120 warnings.push(ModelWarning::LossyEncode {
1124 field: "stopReason".into(),
1125 detail: "Bedrock Converse response carried no stopReason — \
1126 IR records `Other{raw:\"missing\"}`"
1127 .into(),
1128 });
1129 StopReason::Other {
1130 raw: "missing".to_owned(),
1131 }
1132 }
1133 }
1134}
1135
1136fn decode_usage(usage: Option<&Value>) -> Usage {
1137 Usage {
1138 input_tokens: u_field(usage, "inputTokens"),
1139 output_tokens: u_field(usage, "outputTokens"),
1140 cached_input_tokens: u_field(usage, "cacheReadInputTokens"),
1141 cache_creation_input_tokens: u_field(usage, "cacheWriteInputTokens"),
1142 reasoning_tokens: 0,
1143 safety_ratings: Vec::new(),
1144 }
1145}
1146
1147fn str_field<'a>(v: &'a Value, key: &str) -> &'a str {
1148 v.get(key).and_then(Value::as_str).unwrap_or("") }
1150
1151fn u_field(v: Option<&Value>, key: &str) -> u32 {
1152 v.and_then(|inner| inner.get(key))
1153 .and_then(Value::as_u64)
1154 .map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)) }