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    // Operator-supplied `response_format` schemas route through the
296    // same envelope strip every advertised tool receives via
297    // `SchemaToolAdapter` — both Native (`output_config.format.schema`)
298    // and Tool (`tools[0].input_schema`) paths consume the
299    // pre-stripped form.
300    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            // Forced-tool dispatch on Bedrock-Anthropic. The tool
323            // and tool_choice ride through `additionalModelRequestFields`
324            // since the Converse top-level `toolConfig` is the
325            // wire's own tool surface for Bedrock; mixing
326            // structured-output forced calls there would conflict
327            // with operator-supplied tools. Anthropic Messages API
328            // semantics live inside the passthrough.
329            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
373/// Bedrock-on-Anthropic family detection — Claude models routed
374/// through Converse accept Anthropic's `thinking` shape via
375/// `additionalModelRequestFields`. Other Bedrock model families
376/// (Nova, Mistral, Llama) have no thinking surface today; the
377/// codec emits `LossyEncode` and drops the knob.
378fn is_bedrock_anthropic(model: &str) -> bool {
379    // Bedrock Anthropic model IDs include the cross-region inference
380    // prefixes (e.g. `us.anthropic.claude-…`, `eu.anthropic.claude-…`)
381    // alongside the bare `anthropic.claude-…` form.
382    model.contains("anthropic.claude-")
383}
384
385/// Anthropic-on-Bedrock adaptive-only detection — Opus 4.7 hosted
386/// on Bedrock inherits the same constraint (manual budget rejected).
387fn is_bedrock_anthropic_adaptive_only(model: &str) -> bool {
388    is_bedrock_anthropic(model) && model.contains("claude-opus-4-7")
389}
390
391/// Translate the cross-vendor [`ReasoningEffort`] knob onto the
392/// Bedrock Converse `additionalModelRequestFields.thinking`
393/// passthrough. Reuses the Anthropic mapping () for
394/// Anthropic-family models on Bedrock; non-Anthropic models emit
395/// `LossyEncode` and drop the knob.
396fn 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(); // silent-fallback-ok: caller-initiated additionalModelRequestFields nesting — fresh empty Map when absent or non-object
476    additional.insert("thinking".into(), thinking);
477    body.insert(
478        "additionalModelRequestFields".into(),
479        Value::Object(additional),
480    );
481}
482
483/// Read [`crate::ir::BedrockExt`] and merge each set field into the
484/// wire body. Foreign-vendor extensions surface as
485/// [`ModelWarning::ProviderExtensionIgnored`] — the operator
486/// expressed an intent the Bedrock Converse format cannot honour.
487fn 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    // IR `parallel_tool_calls` has no native Bedrock Converse
508    // toggle (the on-Anthropic field rides under `tool_choice` but
509    // Converse does not surface it); emit a field-precise lossy
510    // signal so the operator sees the drop.
511    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            // Bedrock-Anthropic relays Anthropic's `metadata.user_id`
522            // through `additionalModelRequestFields`, mirroring direct
523            // Anthropic. Non-Anthropic Bedrock models (Llama, Nova,
524            // Mistral) lack the channel — fall through to LossyEncode.
525            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
585// ── encode helpers ─────────────────────────────────────────────────────────
586
587fn encode_messages(
588    request: &ModelRequest,
589    warnings: &mut Vec<ModelWarning>,
590) -> (Vec<Value>, Vec<Value>) {
591    // Bedrock Converse system surface is `[{text, cachePoint?}, ...]`.
592    // The `cachePoint` marker goes AFTER the text block it should
593    // cache (Bedrock's documented contract). `attach_cache_point`
594    // shares the TTL coercion + warning logic with the message and
595    // tool encoders below.
596    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"); // silent-fallback-ok: defense-in-depth — split() on a non-empty MIME ("image/...") always yields ≥1 segment; "png" is the Bedrock-documented image default
669                    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"); // silent-fallback-ok: defense-in-depth — split() on a non-empty MIME always yields ≥1 segment; "pdf" is the Bedrock-documented document default
700                    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
868/// Read the optional `cache_control` field from any `ContentPart`
869/// variant. Variants the IR documents as carrying a cache directive
870/// (text / image / audio / video / document / thinking / citation /
871/// tool_result) return their stored value; variants that are model
872/// output and emitted fresh per turn (tool_use / image_output /
873/// audio_output) carry no directive and return `None`.
874const 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    // Bedrock Converse `toolConfig` ships function tools natively;
893    // vendor built-ins ride the `additionalModelRequestFields`
894    // passthrough on the underlying model (Anthropic on Bedrock).
895    // Either way the surface here only emits a `toolSpec` shim per
896    // function-shaped tool — vendor built-ins surface as
897    // `LossyEncode` so the operator routes them through codec
898    // selection rather than expecting Bedrock to bridge.
899    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
929/// Push a Bedrock Converse `cachePoint` marker after `out`'s most
930/// recent block when `cache` is set. Emits a `LossyEncode` warning
931/// when the IR's TTL diverges from Bedrock's vendor default — the
932/// `cachePoint` block has no TTL knob (cache lifetime is set per
933/// model server-side; 5-minute default on Converse, 1h needs the
934/// InvokeModel API). The `field` prefix locates the originating IR
935/// site for operator diagnostics.
936fn 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        // Bedrock has no explicit "none" — fall back to auto so the model
960        // may decline tools naturally (verified against the Converse spec).
961        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
984// ── decode helpers ─────────────────────────────────────────────────────────
985
986fn 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); // silent-fallback-ok: response with no output.message → Null (downstream nested accessors propagate as None)
992    let parts_raw = message
993        .get("content")
994        .and_then(Value::as_array)
995        .cloned()
996        .unwrap_or_default(); // silent-fallback-ok: message with no content array → empty Vec (downstream loop iterates over zero items)
997    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("") // silent-fallback-ok: reasoningContent without reasoningText.text → empty body; downstream is_empty() guard suppresses the part
1011                .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!({})); // silent-fallback-ok: toolUse without input = empty-args call (vendor sometimes omits when schema has no required fields)
1051            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        // T18: Bedrock Converse does not surface the matched stop
1074        // sequence in a top-level field. Some model providers route
1075        // it through `additionalModelResponseFields.stop_sequence`;
1076        // when present we preserve it, otherwise we record `Other`
1077        // plus a LossyEncode warning so the loss is observable
1078        // (invariant #15) rather than silently producing `""`.
1079        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        // Documented Converse stop reasons that don't map onto the
1107        // cross-vendor IR variants — surface verbatim under
1108        // `Other{raw}` so operators can branch on the typed tag
1109        // without parsing the raw string from a warning channel.
1110        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            // Invariant #15 — silent EndTurn fallback would mask
1127            // truncated stream payloads. Emit a LossyEncode warning
1128            // and surface as `Other{raw:"missing"}`.
1129            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("") // silent-fallback-ok: missing optional string field
1155}
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)) // silent-fallback-ok: missing usage metric = 0 (vendor didn't report = unused); u64→u32 saturate
1161}