Skip to main content

yapi_ai/api/
bedrock.rs

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