Skip to main content

entelix_core/codecs/
bedrock_converse.rs

1//! `BedrockConverseCodec` — IR ⇄ AWS Bedrock Converse API
2//! (`POST /model/{modelId}/converse`,
3//!  `POST /model/{modelId}/converse-stream`).
4//!
5//! Wire format reference:
6//! <https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html>.
7//!
8//! Notable mappings:
9//!
10//! - IR `messages` → `messages: [{role, content: [{...}]}]`. Roles are
11//!   `"user"` / `"assistant"`; system prompts live at the top level.
12//! - IR `system: Option<String>` + IR `Role::System` → top-level
13//!   `system: [{text: "..."}]` array.
14//! - IR `Role::Tool` `ToolResult` → wrapped on the wire as a
15//!   `role: "user"` message containing
16//!   `[{toolResult: {toolUseId, content, status}}]` (Bedrock represents
17//!   tool outputs as user-authored content).
18//! - IR `ContentPart::ToolUse` → `[{toolUse: {toolUseId, name, input}}]`.
19//! - IR `tools` / `tool_choice` → `toolConfig: {tools, toolChoice}`.
20//!
21//! **Streaming defers**: AWS Bedrock streams use the binary
22//! `application/vnd.amazon.eventstream` framing format which lives in
23//! `entelix-cloud` alongside `SigV4` signing. `decode_stream` here uses
24//! the `Codec` trait's default fallback (buffer-then-decode); a real
25//! token-level streaming impl lands in the cloud crate.
26
27#![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
41/// Provider key for [`BedrockConverseCodec`] — matches `Codec::name`
42/// and identifies this vendor's entries in [`ProviderEchoSnapshot`].
43/// Bedrock Converse hosts both Anthropic Claude and Amazon Nova
44/// reasoning models under the identical `reasoningContent` wire shape;
45/// model-family branching lives inside this codec, not on the IR.
46const PROVIDER_KEY: &str = "bedrock-converse";
47
48const DEFAULT_MAX_CONTEXT_TOKENS: u32 = 200_000;
49
50/// Stateless codec for the AWS Bedrock Converse API.
51#[derive(Clone, Copy, Debug, Default)]
52pub struct BedrockConverseCodec;
53
54impl BedrockConverseCodec {
55    /// Create a fresh codec instance.
56    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        // Bedrock-Anthropic mirrors direct Anthropic — prefer the
87        // forced-tool surface (more mature, parity across
88        // Anthropic versions on Bedrock). Non-Anthropic Bedrock
89        // models default to `Native` but encode emits `LossyEncode`
90        // on `response_format` since Nova / Mistral / Llama on
91        // Converse have no canonical json_schema channel today.
92        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(); // Bedrock Converse responses have no top-level id
118        let model = String::new(); // model echoed via header in real responses
119        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        // AWS Bedrock returns invocation-quality signals on
135        // `x-amzn-bedrock-*` headers (input/output token counts and a
136        // measured invocation latency). Throttling itself surfaces
137        // through `Retry-After` / `x-amzn-errortype` on the error
138        // path. Capture every Bedrock-prefixed header into the raw
139        // map so operators can build dashboards without per-codec
140        // wire knowledge; promote the documented token-count
141        // signals into typed fields where the IR has a slot.
142        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        // Bedrock's throttle rate-limit headers are not standardised
155        // today; if Retry-After is present (set by the gateway under
156        // load) propagate it so retry classifiers can honour it.
157        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
165// ── body builders ──────────────────────────────────────────────────────────
166
167fn 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        // Bedrock Converse `inferenceConfig` does not expose `top_k`;
200        // for Anthropic-family models the parameter rides via
201        // `additionalModelRequestFields.top_k`. Other Bedrock model
202        // families (Nova, Mistral, Llama, …) have no `top_k`
203        // equivalent — surface a typed lossy snap so the operator
204        // sees the drop rather than a silently ignored field.
205        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(); // silent-fallback-ok: caller-initiated additionalModelRequestFields nesting — fresh empty Map when absent or non-object
213            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
247/// Resolve [`OutputStrategy`] and emit either the Anthropic-on-
248/// Bedrock native (`additionalModelRequestFields.output_config`),
249/// the Bedrock-Anthropic forced-tool surface
250/// (`additionalModelRequestFields.tool_choice`), or the
251/// non-Anthropic Bedrock native fallback (currently `LossyEncode`
252/// since Nova / Mistral / Llama on Converse have no canonical
253/// json_schema channel today). `Auto` resolves to `Tool` for
254/// Anthropic-family models (parity with the direct Anthropic
255/// codec) and `Native` otherwise.
256fn 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        // Bedrock Nova / Mistral / Llama have no canonical
275        // structured-output channel on Converse today. Future
276        // codec updates can extend per-family encode here; for
277        // 1.0 the typed loss surface gives operators a clear
278        // signal.
279        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(); // silent-fallback-ok: caller-initiated additionalModelRequestFields nesting — fresh empty Map when absent or non-object
295    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            // Forced-tool dispatch on Bedrock-Anthropic. The tool
317            // and tool_choice ride through `additionalModelRequestFields`
318            // since the Converse top-level `toolConfig` is the
319            // wire's own tool surface for Bedrock; mixing
320            // structured-output forced calls there would conflict
321            // with operator-supplied tools. Anthropic Messages API
322            // semantics live inside the passthrough.
323            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
367/// Bedrock-on-Anthropic family detection — Claude models routed
368/// through Converse accept Anthropic's `thinking` shape via
369/// `additionalModelRequestFields`. Other Bedrock model families
370/// (Nova, Mistral, Llama) have no thinking surface today; the
371/// codec emits `LossyEncode` and drops the knob.
372fn is_bedrock_anthropic(model: &str) -> bool {
373    // Bedrock Anthropic model IDs include the cross-region inference
374    // prefixes (e.g. `us.anthropic.claude-…`, `eu.anthropic.claude-…`)
375    // alongside the bare `anthropic.claude-…` form.
376    model.contains("anthropic.claude-")
377}
378
379/// Anthropic-on-Bedrock adaptive-only detection — Opus 4.7 hosted
380/// on Bedrock inherits the same constraint (manual budget rejected).
381fn is_bedrock_anthropic_adaptive_only(model: &str) -> bool {
382    is_bedrock_anthropic(model) && model.contains("claude-opus-4-7")
383}
384
385/// Translate the cross-vendor [`ReasoningEffort`] knob onto the
386/// Bedrock Converse `additionalModelRequestFields.thinking`
387/// passthrough. Reuses the Anthropic mapping () for
388/// Anthropic-family models on Bedrock; non-Anthropic models emit
389/// `LossyEncode` and drop the knob.
390fn 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(); // silent-fallback-ok: caller-initiated additionalModelRequestFields nesting — fresh empty Map when absent or non-object
470    additional.insert("thinking".into(), thinking);
471    body.insert(
472        "additionalModelRequestFields".into(),
473        Value::Object(additional),
474    );
475}
476
477/// Read [`crate::ir::BedrockExt`] and merge each set field into the
478/// wire body. Foreign-vendor extensions surface as
479/// [`ModelWarning::ProviderExtensionIgnored`] — the operator
480/// expressed an intent the Bedrock Converse format cannot honour.
481fn 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    // IR `parallel_tool_calls` has no native Bedrock Converse
502    // toggle (the on-Anthropic field rides under `tool_choice` but
503    // Converse does not surface it); emit a field-precise lossy
504    // signal so the operator sees the drop.
505    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            // Bedrock-Anthropic relays Anthropic's `metadata.user_id`
516            // through `additionalModelRequestFields`, mirroring direct
517            // Anthropic. Non-Anthropic Bedrock models (Llama, Nova,
518            // Mistral) lack the channel — fall through to LossyEncode.
519            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
579// ── encode helpers ─────────────────────────────────────────────────────────
580
581fn encode_messages(
582    request: &ModelRequest,
583    warnings: &mut Vec<ModelWarning>,
584) -> (Vec<Value>, Vec<Value>) {
585    // Bedrock Converse system surface is `[{text, cachePoint?}, ...]`.
586    // The `cachePoint` marker goes AFTER the text block it should
587    // cache (Bedrock's documented contract). `attach_cache_point`
588    // shares the TTL coercion + warning logic with the message and
589    // tool encoders below.
590    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"); // silent-fallback-ok: defense-in-depth — split() on a non-empty MIME ("image/...") always yields ≥1 segment; "png" is the Bedrock-documented image default
663                    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"); // silent-fallback-ok: defense-in-depth — split() on a non-empty MIME always yields ≥1 segment; "pdf" is the Bedrock-documented document default
694                    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
862/// Read the optional `cache_control` field from any `ContentPart`
863/// variant. Variants the IR documents as carrying a cache directive
864/// (text / image / audio / video / document / thinking / citation /
865/// tool_result) return their stored value; variants that are model
866/// output and emitted fresh per turn (tool_use / image_output /
867/// audio_output) carry no directive and return `None`.
868const 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    // Bedrock Converse `toolConfig` ships function tools natively;
887    // vendor built-ins ride the `additionalModelRequestFields`
888    // passthrough on the underlying model (Anthropic on Bedrock).
889    // Either way the surface here only emits a `toolSpec` shim per
890    // function-shaped tool — vendor built-ins surface as
891    // `LossyEncode` so the operator routes them through codec
892    // selection rather than expecting Bedrock to bridge.
893    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
923/// Push a Bedrock Converse `cachePoint` marker after `out`'s most
924/// recent block when `cache` is set. Emits a `LossyEncode` warning
925/// when the IR's TTL diverges from Bedrock's vendor default — the
926/// `cachePoint` block has no TTL knob (cache lifetime is set per
927/// model server-side; 5-minute default on Converse, 1h needs the
928/// InvokeModel API). The `field` prefix locates the originating IR
929/// site for operator diagnostics.
930fn 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        // Bedrock has no explicit "none" — fall back to auto so the model
954        // may decline tools naturally (verified against the Converse spec).
955        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
978// ── decode helpers ─────────────────────────────────────────────────────────
979
980fn 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); // silent-fallback-ok: response with no output.message → Null (downstream nested accessors propagate as None)
986    let parts_raw = message
987        .get("content")
988        .and_then(Value::as_array)
989        .cloned()
990        .unwrap_or_default(); // silent-fallback-ok: message with no content array → empty Vec (downstream loop iterates over zero items)
991    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("") // silent-fallback-ok: reasoningContent without reasoningText.text → empty body; downstream is_empty() guard suppresses the part
1005                .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!({})); // silent-fallback-ok: toolUse without input = empty-args call (vendor sometimes omits when schema has no required fields)
1045            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        // T18: Bedrock Converse does not surface the matched stop
1068        // sequence in a top-level field. Some model providers route
1069        // it through `additionalModelResponseFields.stop_sequence`;
1070        // when present we preserve it, otherwise we record `Other`
1071        // plus a LossyEncode warning so the loss is observable
1072        // (invariant #15) rather than silently producing `""`.
1073        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        // Documented Converse stop reasons that don't map onto the
1101        // cross-vendor IR variants — surface verbatim under
1102        // `Other{raw}` so operators can branch on the typed tag
1103        // without parsing the raw string from a warning channel.
1104        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            // Invariant #15 — silent EndTurn fallback would mask
1121            // truncated stream payloads. Emit a LossyEncode warning
1122            // and surface as `Other{raw:"missing"}`.
1123            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("") // silent-fallback-ok: missing optional string field
1149}
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)) // silent-fallback-ok: missing usage metric = 0 (vendor didn't report = unused); u64→u32 saturate
1155}