diff --git a/CHANGELOG.md b/CHANGELOG.md index cd539d910cd..3e29a0a0396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- AWS Bedrock LLM provider via native Converse API with bearer token, IAM, and SSO auth support (feature-gated: `--features bedrock`) + ## [0.13.0](https://github.com/nearai/ironclaw/compare/v0.12.0...v0.13.0) - 2026-03-02 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 11f2effcffc..2429382ef9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -403,6 +403,14 @@ SKILLS_AUTO_DISCOVER=true # Scan skill directories on startup # Tinfoil private inference TINFOIL_API_KEY=... # Required when LLM_BACKEND=tinfoil TINFOIL_MODEL=kimi-k2-5 # Default model + +# AWS Bedrock (native Converse API, requires --features bedrock) +LLM_BACKEND=bedrock +BEDROCK_REGION=us-east-1 # AWS region +BEDROCK_MODEL=anthropic.claude-opus-4-6-v1 # Required model ID +BEDROCK_CROSS_REGION=us # Cross-region prefix (us/eu/apac/global) +AWS_BEARER_TOKEN_BEDROCK=... # Bedrock API key (bearer token auth) +# AWS_PROFILE=my-profile # Named profile (SSO/assume-role) ``` ### LLM Providers @@ -417,6 +425,8 @@ IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` **Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`). +**AWS Bedrock** -- Uses the native Converse API via `aws-sdk-bedrockruntime`. Requires `--features bedrock` at build time (not included in default features due to heavy AWS SDK dependencies). Supports all Bedrock auth methods: bearer token (`AWS_BEARER_TOKEN_BEDROCK`), IAM credentials (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`), SSO profiles (`AWS_PROFILE`), and instance roles. Configure with `BEDROCK_REGION` (default: `us-east-1`), `BEDROCK_MODEL` (required, e.g., `anthropic.claude-opus-4-6-v1`), and `BEDROCK_CROSS_REGION` (optional: `us`, `eu`, `apac`, `global` for cross-region inference profiles). The SDK credential chain resolves auth automatically from the environment. + ## Database IronClaw supports two database backends, selected at compile time via Cargo feature flags and at runtime via the `DATABASE_BACKEND` environment variable. diff --git a/Cargo.lock b/Cargo.lock index 7b90d0bad84..a00d0c8dae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,9 +158,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "ar_archive_writer" @@ -266,7 +266,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] @@ -297,7 +297,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix 1.1.3", ] [[package]] @@ -308,7 +308,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -323,7 +323,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix 1.1.3", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -348,7 +348,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -365,7 +365,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -380,6 +380,412 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-bedrockruntime" +version = "1.126.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31a2249b926688f4fb3c687be770aad39d666a7757ebc83da08bab6d2ad59314" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body-util", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c5ff27c6ba2cbd95e6e26e2e736676fdf6bcf96495b187733f521cfe4ce448" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d186f1e5a3694a188e5a0640b3115ccc6e084d104e16fd6ba968dca072ffef8" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.99.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.3", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.6.20" @@ -492,6 +898,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -526,7 +942,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.117", + "syn 2.0.116", "which", ] @@ -616,12 +1032,12 @@ dependencies = [ "http-body-util", "hyper 1.8.1", "hyper-named-pipe", - "hyper-rustls", + "hyper-rustls 0.27.7", "hyper-util", "hyperlocal", "log", "pin-project-lite", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-pemfile", "rustls-pki-types", @@ -669,14 +1085,14 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" dependencies = [ "allocator-api2", ] @@ -718,6 +1134,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cap-fs-ext" version = "3.4.5" @@ -738,7 +1164,7 @@ checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" dependencies = [ "cap-primitives", "cap-std", - "rustix 1.1.4", + "rustix 1.1.3", "smallvec", ] @@ -754,7 +1180,7 @@ dependencies = [ "io-lifetimes", "ipnet", "maybe-owned", - "rustix 1.1.4", + "rustix 1.1.3", "rustix-linux-procfs", "windows-sys 0.59.0", "winx", @@ -779,7 +1205,7 @@ dependencies = [ "cap-primitives", "io-extras", "io-lifetimes", - "rustix 1.1.4", + "rustix 1.1.3", ] [[package]] @@ -792,7 +1218,7 @@ dependencies = [ "cap-primitives", "iana-time-zone", "once_cell", - "rustix 1.1.4", + "rustix 1.1.3", "winx", ] @@ -840,9 +1266,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -875,9 +1301,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "c5caf74d17c3aec5495110c34cc3f78644bfa89af6c8993ed4de2790e49b6499" dependencies = [ "clap_builder", "clap_derive", @@ -885,9 +1311,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.5.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "370daa45065b80218950227371916a1633217ae42b2715b2287b606dcd618e24" dependencies = [ "anstream", "anstyle", @@ -913,7 +1339,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -931,6 +1357,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1215,7 +1650,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1313,7 +1748,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.1.4", + "rustix 1.1.3", "signal-hook", "signal-hook-mio", "winapi", @@ -1365,7 +1800,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1401,7 +1836,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1425,7 +1860,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1436,7 +1871,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1509,14 +1944,14 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", "serde_core", @@ -1541,7 +1976,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1631,7 +2066,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1675,6 +2110,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -1774,7 +2215,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -1873,7 +2314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.1.4", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -1965,7 +2406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" dependencies = [ "io-lifetimes", - "rustix 1.1.4", + "rustix 1.1.3", "windows-sys 0.59.0", ] @@ -1979,6 +2420,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -2064,7 +2511,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -2336,11 +2783,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.12" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2354,9 +2801,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.25.1" +version = "2.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05335c6bf406653110ad8447c84461c6d0cda5e0aff9d3d3518f87502d30abe" +checksum = "bb31d75f2fdbc8d889d78a912e10c22c30451afb44ee3310f5bfcabf79a31a17" dependencies = [ "ahash 0.8.12", "astral-tl", @@ -2526,6 +2973,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -2535,11 +2997,11 @@ dependencies = [ "http 1.4.0", "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -2834,6 +3296,9 @@ dependencies = [ "aho-corasick", "anyhow", "async-trait", + "aws-config", + "aws-sdk-bedrockruntime", + "aws-smithy-types", "axum 0.8.8", "base64 0.22.1", "blake3", @@ -2873,7 +3338,7 @@ dependencies = [ "rig-core", "rust_decimal", "rust_decimal_macros", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustyline", "secrecy", @@ -2981,9 +3446,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -3024,7 +3489,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3081,7 +3546,7 @@ checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.11.0", "libc", - "redox_syscall 0.7.2", + "redox_syscall 0.7.1", ] [[package]] @@ -3217,9 +3682,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -3349,7 +3814,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.3", ] [[package]] @@ -3534,9 +3999,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" @@ -3679,7 +4144,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3725,6 +4190,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -3776,7 +4247,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3883,7 +4354,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3922,7 +4393,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -3974,7 +4445,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -4082,7 +4553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4123,7 +4594,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4179,7 +4650,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.36", "socket2 0.6.2", "thiserror 2.0.18", "tokio", @@ -4199,7 +4670,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash 2.1.1", - "rustls", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -4370,9 +4841,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d94dd2f7cd932d4dc02cc8b2b50dfd38bd079a4e5d79198b99743d7fcf9a4b4" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ "bitflags 2.11.0", ] @@ -4416,7 +4887,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4461,7 +4932,7 @@ dependencies = [ "quote", "refinery-core", "regex", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4501,11 +4972,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rend" @@ -4532,7 +5009,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "hyper 1.8.1", - "hyper-rustls", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -4543,7 +5020,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.36", "rustls-native-certs", "rustls-pki-types", "serde", @@ -4552,7 +5029,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.3", "tower-http 0.6.8", @@ -4663,7 +5140,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4708,14 +5185,14 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -4726,19 +5203,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" dependencies = [ "once_cell", - "rustix 1.1.4", + "rustix 1.1.3", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.9", "subtle", "zeroize", ] @@ -4774,12 +5264,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4822,7 +5323,7 @@ checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4883,7 +5384,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -4907,6 +5408,16 @@ dependencies = [ "tendril 0.4.3", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4944,9 +5455,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.7.0" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", @@ -4957,9 +5468,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.17.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -5040,7 +5551,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5051,7 +5562,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5086,7 +5597,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5112,9 +5623,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ "base64 0.22.1", "chrono", @@ -5131,14 +5642,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5400,7 +5911,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5411,7 +5922,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5433,9 +5944,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" dependencies = [ "proc-macro2", "quote", @@ -5465,7 +5976,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5530,14 +6041,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.26.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", "getrandom 0.4.1", "once_cell", - "rustix 1.1.4", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -5651,7 +6162,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5662,7 +6173,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5676,9 +6187,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", @@ -5691,15 +6202,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -5757,7 +6268,7 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5796,7 +6307,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -5843,20 +6354,30 @@ checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ "const-oid", "ring", - "rustls", + "rustls 0.23.36", "tokio", "tokio-postgres", - "tokio-rustls", + "tokio-rustls 0.26.4", "x509-cert", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.36", "tokio", ] @@ -6160,7 +6681,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6446,6 +6967,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -6482,11 +7009,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -6495,7 +7022,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -6509,9 +7036,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -6522,9 +7049,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.63" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", "futures-util", @@ -6536,9 +7063,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6546,22 +7073,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -6777,7 +7304,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser 0.221.3", @@ -6893,7 +7420,7 @@ checksum = "1e91092e6cf77390eeccee273846a9327f3e8f91c3c6280f60f37809f0e62d29" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -6988,9 +7515,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.90" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -7069,7 +7596,7 @@ dependencies = [ "proc-macro2", "quote", "shellexpand", - "syn 2.0.117", + "syn 2.0.116", "witx", ] @@ -7081,7 +7608,7 @@ checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wiggle-generate", ] @@ -7154,7 +7681,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7165,7 +7692,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7453,6 +7980,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7483,7 +8016,7 @@ dependencies = [ "heck", "indexmap 2.13.0", "prettyplease", - "syn 2.0.117", + "syn 2.0.116", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -7499,7 +8032,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -7605,7 +8138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix 1.1.3", ] [[package]] @@ -7618,6 +8151,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yansi" version = "1.0.1" @@ -7643,7 +8182,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "synstructure", ] @@ -7695,7 +8234,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "zvariant_utils", ] @@ -7737,7 +8276,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7748,7 +8287,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7768,7 +8307,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "synstructure", ] @@ -7789,7 +8328,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7822,7 +8361,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] [[package]] @@ -7881,7 +8420,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", "zvariant_utils", ] @@ -7893,5 +8432,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.116", ] diff --git a/Cargo.toml b/Cargo.toml index 37ccedc8fb2..f48564204de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,11 @@ subtle = "2" # Constant-time comparisons for token validation # Multi-provider LLM support rig-core = "0.30" +# AWS Bedrock (native Converse API, opt-in via --features bedrock) +aws-config = { version = "1", features = ["behavior-version-latest"], optional = true } +aws-sdk-bedrockruntime = { version = "1", optional = true } +aws-smithy-types = { version = "1", optional = true } + # Docker sandbox bollard = "0.18" @@ -193,6 +198,7 @@ postgres = [ libsql = ["dep:libsql"] integration = [] html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"] +bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"] [[test]] name = "html_to_markdown" diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 71472ec570a..ef5dcde0cdb 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -215,7 +215,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | NEAR AI | βœ… | βœ… | - | Primary provider | | Anthropic (Claude) | βœ… | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 | | OpenAI | βœ… | 🚧 | - | Via NEAR AI proxy | -| AWS Bedrock | βœ… | ❌ | P3 | | +| AWS Bedrock | βœ… | βœ… | - | Native Converse API via aws-sdk-bedrockruntime | | Google Gemini | βœ… | ❌ | P3 | | | NVIDIA API | βœ… | ❌ | P3 | New provider | | OpenRouter | βœ… | βœ… | - | Via OpenAI-compatible provider (RigAdapter) | diff --git a/docs/LLM_PROVIDERS.md b/docs/LLM_PROVIDERS.md index b6d6cf12126..280aabec936 100644 --- a/docs/LLM_PROVIDERS.md +++ b/docs/LLM_PROVIDERS.md @@ -12,6 +12,7 @@ configurations. | Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | Claude models | | OpenAI | `openai` | `OPENAI_API_KEY` | GPT models | | Ollama | `ollama` | No | Local inference | +| AWS Bedrock | `bedrock` | `AWS_BEARER_TOKEN_BEDROCK` or AWS creds | Native Converse API | | OpenRouter | `openai_compatible` | `LLM_API_KEY` | 300+ models | | Together AI | `openai_compatible` | `LLM_API_KEY` | Fast inference | | Fireworks AI | `openai_compatible` | `LLM_API_KEY` | Fast inference | @@ -68,6 +69,62 @@ Pull a model first: `ollama pull llama3.2` --- +## AWS Bedrock (requires `--features bedrock`) + +Uses the native AWS Converse API via `aws-sdk-bedrockruntime`. Supports all Bedrock +authentication methods: API key (bearer token), IAM credentials, SSO profiles, and +instance roles. + +### With Bedrock API key (bearer token) + +```env +LLM_BACKEND=bedrock +BEDROCK_MODEL=anthropic.claude-opus-4-6-v1 +BEDROCK_REGION=us-east-1 +BEDROCK_CROSS_REGION=us +AWS_BEARER_TOKEN_BEDROCK= +``` + +Generate a Bedrock API key from: AWS Console β†’ Bedrock β†’ API keys. + +### With AWS credentials (IAM, SSO, instance roles) + +```env +LLM_BACKEND=bedrock +BEDROCK_MODEL=anthropic.claude-opus-4-6-v1 +BEDROCK_REGION=us-east-1 +BEDROCK_CROSS_REGION=us +# AWS_PROFILE=my-sso-profile # optional, for named profiles +``` + +The AWS SDK credential chain automatically resolves credentials from environment +variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), shared credentials file +(`~/.aws/credentials`), SSO profiles, and EC2/ECS instance roles. + +### Cross-region inference + +Set `BEDROCK_CROSS_REGION` to route requests across AWS regions for capacity: + +| Prefix | Routing | +|---|---| +| `us` | US regions (us-east-1, us-east-2, us-west-2) | +| `eu` | European regions | +| `apac` | Asia-Pacific regions | +| `global` | All commercial AWS regions | +| _(unset)_ | Single-region only | + +### Popular Bedrock model IDs + +| Model | ID | +|---|---| +| Claude Opus 4.6 | `anthropic.claude-opus-4-6-v1` | +| Claude Sonnet 4.5 | `anthropic.claude-sonnet-4-5-20250929-v1:0` | +| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5-20251001-v1:0` | +| Amazon Nova Pro | `amazon.nova-pro-v1:0` | +| Llama 4 Maverick | `meta.llama4-maverick-17b-instruct-v1:0` | + +--- + ## OpenAI-Compatible Endpoints All providers below use `LLM_BACKEND=openai_compatible`. Set `LLM_BASE_URL` to the diff --git a/src/config/llm.rs b/src/config/llm.rs index ba42ed9d8d0..29aa5825662 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -26,6 +26,8 @@ pub enum LlmBackend { OpenAiCompatible, /// Tinfoil private inference Tinfoil, + /// AWS Bedrock (native Converse API) + Bedrock, } impl std::str::FromStr for LlmBackend { @@ -39,8 +41,9 @@ impl std::str::FromStr for LlmBackend { "ollama" => Ok(Self::Ollama), "openai_compatible" | "openai-compatible" | "compatible" => Ok(Self::OpenAiCompatible), "tinfoil" => Ok(Self::Tinfoil), + "bedrock" | "aws_bedrock" | "aws" => Ok(Self::Bedrock), _ => Err(format!( - "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil", + "invalid LLM backend '{}', expected one of: nearai, openai, anthropic, ollama, openai_compatible, tinfoil, bedrock", s )), } @@ -56,6 +59,7 @@ impl std::fmt::Display for LlmBackend { Self::Ollama => write!(f, "ollama"), Self::OpenAiCompatible => write!(f, "openai_compatible"), Self::Tinfoil => write!(f, "tinfoil"), + Self::Bedrock => write!(f, "bedrock"), } } } @@ -73,6 +77,7 @@ impl LlmBackend { Self::Ollama => "OLLAMA_MODEL", Self::OpenAiCompatible => "LLM_MODEL", Self::Tinfoil => "TINFOIL_MODEL", + Self::Bedrock => "BEDROCK_MODEL", } } } @@ -120,6 +125,19 @@ pub struct TinfoilConfig { pub model: String, } +/// Configuration for AWS Bedrock (native Converse API). +#[derive(Debug, Clone)] +pub struct BedrockConfig { + /// AWS region (e.g. "us-east-1"). + pub region: String, + /// Bedrock model ID (e.g. "anthropic.claude-opus-4-6-v1"). + pub model: String, + /// Cross-region inference prefix: "us", "eu", "apac", "global", or None. + pub cross_region: Option, + /// AWS named profile (for SSO / assume-role workflows). + pub profile: Option, +} + /// LLM provider configuration. /// /// NEAR AI remains the default backend. Users can switch to other providers @@ -140,6 +158,8 @@ pub struct LlmConfig { pub openai_compatible: Option, /// Tinfoil config (populated when backend=tinfoil) pub tinfoil: Option, + /// AWS Bedrock config (populated when backend=bedrock) + pub bedrock: Option, } /// NEAR AI configuration. @@ -350,6 +370,43 @@ impl LlmConfig { None }; + let bedrock = if backend == LlmBackend::Bedrock { + let explicit_region = + optional_env("BEDROCK_REGION")?.or_else(|| settings.bedrock_region.clone()); + if explicit_region.is_none() { + tracing::info!("BEDROCK_REGION not set, defaulting to us-east-1"); + } + let region = explicit_region.unwrap_or_else(|| "us-east-1".to_string()); + let model = optional_env("BEDROCK_MODEL")? + .or_else(|| settings.selected_model.clone()) + .ok_or_else(|| ConfigError::MissingRequired { + key: "BEDROCK_MODEL".to_string(), + hint: "Set BEDROCK_MODEL when LLM_BACKEND=bedrock".to_string(), + })?; + let cross_region = optional_env("BEDROCK_CROSS_REGION")? + .or_else(|| settings.bedrock_cross_region.clone()); + if let Some(ref cr) = cross_region + && !matches!(cr.as_str(), "us" | "eu" | "apac" | "global") + { + return Err(ConfigError::InvalidValue { + key: "BEDROCK_CROSS_REGION".to_string(), + message: format!( + "'{}' is not valid, expected one of: us, eu, apac, global", + cr + ), + }); + } + let profile = optional_env("AWS_PROFILE")?; + Some(BedrockConfig { + region, + model, + cross_region, + profile, + }) + } else { + None + }; + Ok(Self { backend, nearai, @@ -358,6 +415,7 @@ impl LlmConfig { ollama, openai_compatible, tinfoil, + bedrock, }) } } diff --git a/src/config/mod.rs b/src/config/mod.rs index a89edcf48fc..894a0ac1111 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -37,7 +37,7 @@ pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; pub use self::hygiene::HygieneConfig; pub use self::llm::{ - AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, + AnthropicDirectConfig, BedrockConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig, OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig, }; pub use self::routines::RoutineConfig; @@ -220,6 +220,7 @@ pub async fn inject_llm_keys_from_secrets( ("llm_anthropic_api_key", "ANTHROPIC_API_KEY"), ("llm_compatible_api_key", "LLM_API_KEY"), ("llm_nearai_api_key", "NEARAI_API_KEY"), + ("bedrock_api_key", "AWS_BEARER_TOKEN_BEDROCK"), ]; let mut injected = HashMap::new(); diff --git a/src/llm/bedrock.rs b/src/llm/bedrock.rs new file mode 100644 index 00000000000..78a593ae092 --- /dev/null +++ b/src/llm/bedrock.rs @@ -0,0 +1,1122 @@ +//! AWS Bedrock LLM provider using the native Converse API. +//! +//! Uses `aws-sdk-bedrockruntime` to call `client.converse()` directly, +//! bypassing the OpenAI-compatible layer. Supports all Bedrock auth methods: +//! bearer token (`AWS_BEARER_TOKEN_BEDROCK`), IAM credentials, SSO profiles, +//! and instance roles β€” all handled transparently by the AWS SDK credential chain. + +use std::collections::HashMap; +use std::sync::RwLock; + +use async_trait::async_trait; +use aws_config::{BehaviorVersion, Region}; +use aws_sdk_bedrockruntime::Client; +use aws_sdk_bedrockruntime::operation::converse::ConverseError; +use aws_sdk_bedrockruntime::types::{ + AnyToolChoice, AutoToolChoice, ContentBlock, ConversationRole, InferenceConfiguration, Message, + StopReason, SystemContentBlock, Tool, ToolChoice, ToolConfiguration, ToolInputSchema, + ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock, +}; +use aws_smithy_types::Document; +use rust_decimal::Decimal; + +use crate::config::BedrockConfig; +use crate::error::LlmError; +use crate::llm::provider::{ + CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, +}; + +/// AWS Bedrock provider using the native Converse API. +pub struct BedrockProvider { + client: Client, + /// Base model ID for display purposes (without prefix). + display_model: String, + /// Cross-region prefix (e.g. "us.", "global.") or empty. + cross_region_prefix: String, + /// Active model ID (with cross-region prefix), switchable at runtime via `set_model()`. + active_model: RwLock, +} + +impl BedrockProvider { + /// Create a new Bedrock provider from configuration. + /// + /// Uses `block_in_place` because the AWS SDK config loader is async + /// but `create_llm_provider` is sync. This is safe because IronClaw + /// uses the multi-threaded tokio runtime. + pub fn new(config: &BedrockConfig) -> Result { + let cross_region_prefix = config + .cross_region + .as_ref() + .map(|prefix| format!("{}.", prefix)) + .unwrap_or_default(); + + let model_id = format!("{}{}", cross_region_prefix, config.model); + + let sdk_config = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + let mut builder = aws_config::defaults(BehaviorVersion::latest()) + .region(Region::new(config.region.clone())); + if let Some(ref profile) = config.profile { + builder = builder.profile_name(profile); + } + builder.load().await + }) + }); + + let client = Client::new(&sdk_config); + + Ok(Self { + client, + display_model: config.model.clone(), + cross_region_prefix, + active_model: RwLock::new(model_id), + }) + } + + /// Get the currently active model ID (with cross-region prefix). + fn current_model_id(&self) -> String { + match self.active_model.read() { + Ok(guard) => guard.clone(), + Err(poisoned) => { + tracing::warn!("active_model lock poisoned while reading; continuing"); + poisoned.into_inner().clone() + } + } + } +} + +#[async_trait] +impl LlmProvider for BedrockProvider { + fn model_name(&self) -> &str { + &self.display_model + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + // Bedrock billing is on the AWS bill, not trackable per-token here. + (Decimal::ZERO, Decimal::ZERO) + } + + async fn complete(&self, request: CompletionRequest) -> Result { + let model_id = self.current_model_id(); + + let mut messages = request.messages; + crate::llm::provider::sanitize_tool_messages(&mut messages); + + let (system_blocks, bedrock_messages) = convert_messages(&messages)?; + + if bedrock_messages.is_empty() { + return Err(LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Bedrock requires at least one user or assistant message".to_string(), + }); + } + + let mut builder = self + .client + .converse() + .model_id(&model_id) + .set_system(if system_blocks.is_empty() { + None + } else { + Some(system_blocks) + }) + .set_messages(Some(bedrock_messages)); + + if let Some(config) = build_inference_config(request.temperature, request.max_tokens) { + builder = builder.inference_config(config); + } + + let response = builder.send().await.map_err(|e| map_sdk_error(&e))?; + + let (text, _tool_calls) = extract_content_blocks(response.output())?; + let (input_tokens, output_tokens) = extract_token_usage(response.usage()); + + Ok(CompletionResponse { + content: text, + input_tokens, + output_tokens, + finish_reason: map_stop_reason(response.stop_reason()), + }) + } + + async fn complete_with_tools( + &self, + request: ToolCompletionRequest, + ) -> Result { + let model_id = self.current_model_id(); + + let mut messages = request.messages; + crate::llm::provider::sanitize_tool_messages(&mut messages); + + let (system_blocks, bedrock_messages) = convert_messages(&messages)?; + + if bedrock_messages.is_empty() { + return Err(LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Bedrock requires at least one user or assistant message".to_string(), + }); + } + + let tool_config = build_tool_config(&request.tools, request.tool_choice.as_deref())?; + + let mut builder = self + .client + .converse() + .model_id(&model_id) + .set_system(if system_blocks.is_empty() { + None + } else { + Some(system_blocks) + }) + .set_messages(Some(bedrock_messages)); + + if let Some(tc) = tool_config { + builder = builder.tool_config(tc); + } + + if let Some(config) = build_inference_config(request.temperature, request.max_tokens) { + builder = builder.inference_config(config); + } + + let response = builder.send().await.map_err(|e| map_sdk_error(&e))?; + + let (text, tool_calls) = extract_content_blocks(response.output())?; + let (input_tokens, output_tokens) = extract_token_usage(response.usage()); + + Ok(ToolCompletionResponse { + content: if text.is_empty() { None } else { Some(text) }, + tool_calls, + input_tokens, + output_tokens, + finish_reason: map_stop_reason(response.stop_reason()), + }) + } + + async fn model_metadata(&self) -> Result { + Ok(ModelMetadata { + id: self.current_model_id(), + context_length: None, + }) + } + + fn active_model_name(&self) -> String { + self.current_model_id() + } + + fn effective_model_name(&self, _requested_model: Option<&str>) -> String { + // Bedrock doesn't support per-request model overrides in Converse API; + // the model is part of the request builder, not the message body. + self.active_model_name() + } + + fn set_model(&self, model: &str) -> Result<(), LlmError> { + let new_id = format!("{}{}", self.cross_region_prefix, model); + match self.active_model.write() { + Ok(mut guard) => { + *guard = new_id; + } + Err(poisoned) => { + tracing::warn!("active_model lock poisoned while writing; continuing"); + *poisoned.into_inner() = new_id; + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Inference configuration +// --------------------------------------------------------------------------- + +/// Build an `InferenceConfiguration` from optional temperature and max_tokens. +/// Returns `None` if neither is set. +fn build_inference_config( + temperature: Option, + max_tokens: Option, +) -> Option { + let mut builder = InferenceConfiguration::builder(); + let mut needs_config = false; + + if let Some(temp) = temperature { + builder = builder.temperature(temp); + needs_config = true; + } + if let Some(tokens) = max_tokens { + builder = builder.max_tokens(i32::try_from(tokens).unwrap_or(i32::MAX)); + needs_config = true; + } + + if needs_config { + Some(builder.build()) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Message conversion +// --------------------------------------------------------------------------- + +/// Convert IronClaw `ChatMessage` list into Bedrock system blocks + messages. +/// +/// Key differences from OpenAI/Anthropic protocol: +/// 1. System messages are extracted and passed separately. +/// 2. Tool results (role=Tool) become `ContentBlock::ToolResult` inside User messages. +/// 3. Consecutive tool results are merged into a single User message. +/// 4. Bedrock requires strict user/assistant alternation. +fn convert_messages( + messages: &[crate::llm::provider::ChatMessage], +) -> Result<(Vec, Vec), LlmError> { + use crate::llm::provider::Role; + + let mut system_blocks = Vec::new(); + let mut bedrock_messages: Vec = Vec::new(); + let mut pending_tool_results: Vec = Vec::new(); + + for msg in messages { + match msg.role { + Role::System => { + if !msg.content.is_empty() { + system_blocks.push(SystemContentBlock::Text(msg.content.clone())); + } + } + Role::User => { + // Flush any pending tool results as a User message first + flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; + + let content = vec![ContentBlock::Text(msg.content.clone())]; + push_message(&mut bedrock_messages, ConversationRole::User, content)?; + } + Role::Assistant => { + // Flush any pending tool results before an assistant message + flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; + + let mut content = Vec::new(); + + // Add text content if non-empty + if !msg.content.is_empty() { + content.push(ContentBlock::Text(msg.content.clone())); + } + + // Add tool use blocks if present + if let Some(ref tool_calls) = msg.tool_calls { + for tc in tool_calls { + let input_doc = json_to_document(&tc.arguments); + let tool_use = ToolUseBlock::builder() + .tool_use_id(&tc.id) + .name(&tc.name) + .input(input_doc) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build ToolUseBlock: {}", e), + })?; + content.push(ContentBlock::ToolUse(tool_use)); + } + } + + if !content.is_empty() { + push_message(&mut bedrock_messages, ConversationRole::Assistant, content)?; + } + } + Role::Tool => { + // Accumulate tool results β€” they'll be flushed as a User message + let tool_call_id = msg.tool_call_id.as_deref().unwrap_or("unknown"); + + let status = + if let Ok(json) = serde_json::from_str::(&msg.content) { + if json + .get("is_error") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + Some(ToolResultStatus::Error) + } else { + Some(ToolResultStatus::Success) + } + } else { + Some(ToolResultStatus::Success) + }; + + let tool_result = ToolResultBlock::builder() + .tool_use_id(tool_call_id) + .content(ToolResultContentBlock::Text(msg.content.clone())) + .set_status(status) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build ToolResultBlock: {}", e), + })?; + + pending_tool_results.push(ContentBlock::ToolResult(tool_result)); + } + } + } + + // Flush any remaining tool results + flush_tool_results(&mut pending_tool_results, &mut bedrock_messages)?; + + Ok((system_blocks, bedrock_messages)) +} + +/// Flush accumulated tool result blocks as a single User message. +fn flush_tool_results( + pending: &mut Vec, + messages: &mut Vec, +) -> Result<(), LlmError> { + if pending.is_empty() { + return Ok(()); + } + + let content: Vec = std::mem::take(pending); + push_message(messages, ConversationRole::User, content)?; + + Ok(()) +} + +/// Push a message, enforcing Bedrock's alternation requirement. +/// +/// If the last message has the same role, merge the content blocks into it +/// rather than creating a consecutive same-role message. +fn push_message( + messages: &mut Vec, + role: ConversationRole, + content: Vec, +) -> Result<(), LlmError> { + if content.is_empty() { + return Ok(()); + } + + // Check if we need to merge with the previous message of the same role + if let Some(last) = messages.last() + && *last.role() == role + { + // Remove the last message, merge content, and re-push + let prev = messages.pop().ok_or_else(|| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Unexpected empty message list during merge".to_string(), + })?; + let mut merged = prev.content().to_vec(); + merged.extend(content); + let msg = Message::builder() + .role(role) + .set_content(Some(merged)) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build merged Message: {}", e), + })?; + messages.push(msg); + return Ok(()); + } + + let msg = Message::builder() + .role(role) + .set_content(Some(content)) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build Message: {}", e), + })?; + messages.push(msg); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tool configuration +// --------------------------------------------------------------------------- + +/// Build Bedrock `ToolConfiguration` from IronClaw tool definitions. +fn build_tool_config( + tools: &[ToolDefinition], + tool_choice: Option<&str>, +) -> Result, LlmError> { + if tools.is_empty() { + return Ok(None); + } + + let bedrock_tools: Vec = tools + .iter() + .map(|td| { + let input_schema = ToolInputSchema::Json(json_to_document(&td.parameters)); + let spec = ToolSpecification::builder() + .name(&td.name) + .description(&td.description) + .input_schema(input_schema) + .build() + .map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build ToolSpecification: {}", e), + })?; + Ok(Tool::ToolSpec(spec)) + }) + .collect::, LlmError>>()?; + + let choice = match tool_choice { + Some("none") => { + // If tool_choice is "none", don't send tool config at all + return Ok(None); + } + Some("required") => Some(ToolChoice::Any(AnyToolChoice::builder().build())), + // "auto" or anything else + _ => Some(ToolChoice::Auto(AutoToolChoice::builder().build())), + }; + + let mut builder = ToolConfiguration::builder().set_tools(Some(bedrock_tools)); + if let Some(c) = choice { + builder = builder.tool_choice(c); + } + + let config = builder.build().map_err(|e| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Failed to build ToolConfiguration: {}", e), + })?; + + Ok(Some(config)) +} + +// --------------------------------------------------------------------------- +// Response extraction +// --------------------------------------------------------------------------- + +/// Extract text content and tool calls from the Converse response output. +fn extract_content_blocks( + output: Option<&aws_sdk_bedrockruntime::types::ConverseOutput>, +) -> Result<(String, Vec), LlmError> { + let output = output.ok_or_else(|| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Converse response has no output".to_string(), + })?; + + let message = output.as_message().map_err(|_| LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Converse output is not a message".to_string(), + })?; + + let mut text_parts = Vec::new(); + let mut tool_calls = Vec::new(); + + for block in message.content() { + match block { + ContentBlock::Text(t) => { + text_parts.push(t.clone()); + } + ContentBlock::ToolUse(tu) => { + tool_calls.push(ToolCall { + id: tu.tool_use_id().to_string(), + name: tu.name().to_string(), + arguments: document_to_json(tu.input()), + }); + } + // Ignore reasoning, citations, images, etc. + _ => {} + } + } + + Ok((text_parts.join(""), tool_calls)) +} + +/// Extract token usage from the response, converting i32 β†’ u32 safely. +fn extract_token_usage(usage: Option<&aws_sdk_bedrockruntime::types::TokenUsage>) -> (u32, u32) { + match usage { + Some(u) => ( + u32::try_from(u.input_tokens()).unwrap_or(0), + u32::try_from(u.output_tokens()).unwrap_or(0), + ), + None => (0, 0), + } +} + +/// Map Bedrock `StopReason` to IronClaw `FinishReason`. +fn map_stop_reason(reason: &StopReason) -> FinishReason { + match reason { + StopReason::EndTurn | StopReason::StopSequence => FinishReason::Stop, + StopReason::ToolUse => FinishReason::ToolUse, + StopReason::MaxTokens | StopReason::ModelContextWindowExceeded => FinishReason::Length, + StopReason::ContentFiltered | StopReason::GuardrailIntervened => { + FinishReason::ContentFilter + } + _ => FinishReason::Unknown, + } +} + +// --------------------------------------------------------------------------- +// Error mapping +// --------------------------------------------------------------------------- + +/// Map AWS SDK errors to `LlmError`. +fn map_sdk_error( + error: &aws_sdk_bedrockruntime::error::SdkError, +) -> LlmError { + use aws_sdk_bedrockruntime::error::SdkError; + + match error { + SdkError::ServiceError(service_err) => { + let msg = match service_err.err() { + ConverseError::ModelTimeoutException(e) => { + format!("Model timeout: {}", e.message().unwrap_or("unknown")) + } + ConverseError::ModelNotReadyException(e) => { + format!("Model not ready: {}", e.message().unwrap_or("unknown")) + } + ConverseError::ThrottlingException(e) => { + format!("Throttled: {}", e.message().unwrap_or("unknown")) + } + ConverseError::ValidationException(e) => { + format!("Validation error: {}", e.message().unwrap_or("unknown")) + } + ConverseError::AccessDeniedException(e) => { + format!("Access denied: {}", e.message().unwrap_or("unknown")) + } + ConverseError::ResourceNotFoundException(e) => { + format!("Resource not found: {}", e.message().unwrap_or("unknown")) + } + ConverseError::ModelErrorException(e) => { + format!("Model error: {}", e.message().unwrap_or("unknown")) + } + ConverseError::InternalServerException(e) => { + format!( + "Internal server error: {}", + e.message().unwrap_or("unknown") + ) + } + ConverseError::ServiceUnavailableException(e) => { + format!("Service unavailable: {}", e.message().unwrap_or("unknown")) + } + _ => format!("Bedrock service error: {}", service_err.err()), + }; + LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: msg, + } + } + SdkError::TimeoutError(_) => LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Request timed out".to_string(), + }, + SdkError::DispatchFailure(e) => LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("Connection error: {:?}", e), + }, + _ => LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: format!("AWS SDK error: {}", error), + }, + } +} + +// --------------------------------------------------------------------------- +// Document ↔ serde_json::Value conversion +// --------------------------------------------------------------------------- + +/// Convert `serde_json::Value` to `aws_smithy_types::Document`. +pub(crate) fn json_to_document(value: &serde_json::Value) -> Document { + match value { + serde_json::Value::Null => Document::Null, + serde_json::Value::Bool(b) => Document::Bool(*b), + serde_json::Value::Number(n) => { + if let Some(u) = n.as_u64() { + Document::Number(aws_smithy_types::Number::PosInt(u)) + } else if let Some(i) = n.as_i64() { + Document::Number(aws_smithy_types::Number::NegInt(i)) + } else if let Some(f) = n.as_f64() { + Document::Number(aws_smithy_types::Number::Float(f)) + } else { + Document::Null + } + } + serde_json::Value::String(s) => Document::String(s.clone()), + serde_json::Value::Array(arr) => { + Document::Array(arr.iter().map(json_to_document).collect()) + } + serde_json::Value::Object(obj) => { + let map: HashMap = obj + .iter() + .map(|(k, v)| (k.clone(), json_to_document(v))) + .collect(); + Document::Object(map) + } + } +} + +/// Convert `aws_smithy_types::Document` to `serde_json::Value`. +pub(crate) fn document_to_json(doc: &Document) -> serde_json::Value { + match doc { + Document::Null => serde_json::Value::Null, + Document::Bool(b) => serde_json::Value::Bool(*b), + Document::Number(n) => match n { + aws_smithy_types::Number::PosInt(u) => { + serde_json::Value::Number(serde_json::Number::from(*u)) + } + aws_smithy_types::Number::NegInt(i) => { + serde_json::Value::Number(serde_json::Number::from(*i)) + } + aws_smithy_types::Number::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + }, + Document::String(s) => serde_json::Value::String(s.clone()), + Document::Array(arr) => { + serde_json::Value::Array(arr.iter().map(document_to_json).collect()) + } + Document::Object(obj) => { + let map: serde_json::Map = obj + .iter() + .map(|(k, v)| (k.clone(), document_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::provider::{ChatMessage, Role}; + + #[test] + fn test_json_to_document_round_trip() { + let json = serde_json::json!({ + "name": "test", + "count": 42, + "negative": -7, + "ratio": 3.125, + "active": true, + "nothing": null, + "tags": ["a", "b"], + "nested": {"x": 1} + }); + + let doc = json_to_document(&json); + let back = document_to_json(&doc); + + assert_eq!(json, back); + } + + #[test] + fn test_json_to_document_empty_object() { + let json = serde_json::json!({}); + let doc = json_to_document(&json); + let back = document_to_json(&doc); + assert_eq!(json, back); + } + + #[test] + fn test_convert_messages_system_extraction() { + let messages = vec![ + ChatMessage::system("You are helpful."), + ChatMessage::system("Be concise."), + ChatMessage::user("Hello"), + ]; + + let (system, msgs) = convert_messages(&messages).unwrap(); + + assert_eq!(system.len(), 2); + assert_eq!(msgs.len(), 1); + assert_eq!(*msgs[0].role(), ConversationRole::User); + } + + #[test] + fn test_convert_messages_basic_conversation() { + let messages = vec![ + ChatMessage::user("Hi"), + ChatMessage::assistant("Hello!"), + ChatMessage::user("How are you?"), + ]; + + let (system, msgs) = convert_messages(&messages).unwrap(); + + assert!(system.is_empty()); + assert_eq!(msgs.len(), 3); + assert_eq!(*msgs[0].role(), ConversationRole::User); + assert_eq!(*msgs[1].role(), ConversationRole::Assistant); + assert_eq!(*msgs[2].role(), ConversationRole::User); + } + + #[test] + fn test_convert_messages_tool_results_merge_into_user() { + let tc = crate::llm::provider::ToolCall { + id: "call_1".to_string(), + name: "echo".to_string(), + arguments: serde_json::json!({"text": "hi"}), + }; + let tc2 = crate::llm::provider::ToolCall { + id: "call_2".to_string(), + name: "time".to_string(), + arguments: serde_json::json!({}), + }; + + let messages = vec![ + ChatMessage::user("Do things"), + ChatMessage::assistant_with_tool_calls(None, vec![tc, tc2]), + ChatMessage::tool_result("call_1", "echo", "hi back"), + ChatMessage::tool_result("call_2", "time", "12:00"), + ]; + + let (_, msgs) = convert_messages(&messages).unwrap(); + + // user, assistant (with tool_use), user (with merged tool_results) + assert_eq!(msgs.len(), 3); + assert_eq!(*msgs[2].role(), ConversationRole::User); + // The merged user message should have 2 content blocks (both ToolResult) + assert_eq!(msgs[2].content().len(), 2); + assert!(msgs[2].content()[0].is_tool_result()); + assert!(msgs[2].content()[1].is_tool_result()); + } + + #[test] + fn test_convert_messages_consecutive_users_merge() { + let messages = vec![ChatMessage::user("First"), ChatMessage::user("Second")]; + + let (_, msgs) = convert_messages(&messages).unwrap(); + + // Should merge into a single User message with 2 text blocks + assert_eq!(msgs.len(), 1); + assert_eq!(*msgs[0].role(), ConversationRole::User); + assert_eq!(msgs[0].content().len(), 2); + } + + #[test] + fn test_convert_messages_assistant_with_tool_calls() { + let tc = crate::llm::provider::ToolCall { + id: "call_1".to_string(), + name: "search".to_string(), + arguments: serde_json::json!({"query": "test"}), + }; + + let messages = vec![ + ChatMessage::user("Search for test"), + ChatMessage::assistant_with_tool_calls(Some("Let me search.".to_string()), vec![tc]), + ]; + + let (_, msgs) = convert_messages(&messages).unwrap(); + + assert_eq!(msgs.len(), 2); + assert_eq!(*msgs[1].role(), ConversationRole::Assistant); + // Should have text + tool_use + assert_eq!(msgs[1].content().len(), 2); + assert!(msgs[1].content()[0].is_text()); + assert!(msgs[1].content()[1].is_tool_use()); + } + + #[test] + fn test_convert_messages_empty_assistant_content_with_tool_calls() { + let tc = crate::llm::provider::ToolCall { + id: "call_1".to_string(), + name: "echo".to_string(), + arguments: serde_json::json!({}), + }; + + let messages = vec![ + ChatMessage::user("Go"), + ChatMessage::assistant_with_tool_calls(None, vec![tc]), + ]; + + let (_, msgs) = convert_messages(&messages).unwrap(); + + assert_eq!(msgs.len(), 2); + // Empty text should not add a Text block + let assistant_content = msgs[1].content(); + assert_eq!(assistant_content.len(), 1); + assert!(assistant_content[0].is_tool_use()); + } + + #[test] + fn test_build_tool_config_empty_tools() { + let result = build_tool_config(&[], None).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_build_tool_config_none_choice() { + let result = build_tool_config(&[], Some("none")).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_build_tool_config_with_tools() { + let tools = vec![ToolDefinition { + name: "echo".to_string(), + description: "Echoes input".to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "text": {"type": "string"} + } + }), + }]; + + let result = build_tool_config(&tools, Some("auto")).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn test_map_stop_reason() { + assert_eq!(map_stop_reason(&StopReason::EndTurn), FinishReason::Stop); + assert_eq!( + map_stop_reason(&StopReason::StopSequence), + FinishReason::Stop + ); + assert_eq!(map_stop_reason(&StopReason::ToolUse), FinishReason::ToolUse); + assert_eq!( + map_stop_reason(&StopReason::MaxTokens), + FinishReason::Length + ); + assert_eq!( + map_stop_reason(&StopReason::ContentFiltered), + FinishReason::ContentFilter + ); + } + + #[test] + fn test_model_id_with_cross_region() { + // Simulate what the constructor does + let prefix = "us."; + let model = "anthropic.claude-opus-4-6-v1"; + let model_id = format!("{}{}", prefix, model); + assert_eq!(model_id, "us.anthropic.claude-opus-4-6-v1"); + } + + #[test] + fn test_model_id_without_cross_region() { + let prefix = ""; + let model = "anthropic.claude-opus-4-6-v1"; + let model_id = format!("{}{}", prefix, model); + assert_eq!(model_id, "anthropic.claude-opus-4-6-v1"); + } + + #[test] + fn test_convert_messages_tool_result_after_regular_user() { + // Edge case: tool result appears after a user message (from sanitize_tool_messages rewrite) + // This shouldn't happen normally but we should handle it gracefully + let messages = vec![ + ChatMessage::user("Hello"), + ChatMessage { + role: Role::Tool, + content: "result".to_string(), + tool_call_id: Some("call_1".to_string()), + name: Some("echo".to_string()), + tool_calls: None, + }, + ]; + + let (_, msgs) = convert_messages(&messages).unwrap(); + + // User + tool result (as user) = should merge into one User message + assert_eq!(msgs.len(), 1); + assert_eq!(*msgs[0].role(), ConversationRole::User); + } + + #[test] + fn test_extract_token_usage_present() { + let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder() + .input_tokens(150) + .output_tokens(42) + .total_tokens(192) + .build() + .unwrap(); + let (input, output) = extract_token_usage(Some(&usage)); + assert_eq!(input, 150); + assert_eq!(output, 42); + } + + #[test] + fn test_extract_token_usage_none() { + let (input, output) = extract_token_usage(None); + assert_eq!(input, 0); + assert_eq!(output, 0); + } + + #[test] + fn test_extract_token_usage_negative_clamps_to_zero() { + // Bedrock uses i32; negative values should not panic + let usage = aws_sdk_bedrockruntime::types::TokenUsage::builder() + .input_tokens(-1) + .output_tokens(-5) + .total_tokens(0) + .build() + .unwrap(); + let (input, output) = extract_token_usage(Some(&usage)); + assert_eq!(input, 0); + assert_eq!(output, 0); + } + + #[test] + fn test_json_to_document_nested_arrays() { + let json = serde_json::json!([[1, 2], [3, 4]]); + let doc = json_to_document(&json); + let back = document_to_json(&doc); + assert_eq!(json, back); + } + + #[test] + fn test_json_to_document_large_numbers() { + let json = serde_json::json!({ + "big_pos": u64::MAX, + "big_neg": i64::MIN, + }); + let doc = json_to_document(&json); + let back = document_to_json(&doc); + assert_eq!(json, back); + } + + #[test] + fn test_full_tool_round_trip_conversation() { + // Simulate a complete tool-use conversation: + // system β†’ user β†’ assistant(tool_calls) β†’ tool_results β†’ user follow-up + let tc1 = crate::llm::provider::ToolCall { + id: "call_abc".to_string(), + name: "get_weather".to_string(), + arguments: serde_json::json!({"city": "NYC"}), + }; + let tc2 = crate::llm::provider::ToolCall { + id: "call_def".to_string(), + name: "get_time".to_string(), + arguments: serde_json::json!({"tz": "EST"}), + }; + + let messages = vec![ + ChatMessage::system("You are a helpful assistant."), + ChatMessage::user("What's the weather and time in NYC?"), + ChatMessage::assistant_with_tool_calls( + Some("Let me check both.".to_string()), + vec![tc1, tc2], + ), + ChatMessage::tool_result("call_abc", "get_weather", "72Β°F and sunny"), + ChatMessage::tool_result("call_def", "get_time", "3:45 PM EST"), + ChatMessage::user("Thanks! What about tomorrow?"), + ]; + + let (system, msgs) = convert_messages(&messages).unwrap(); + + // 1 system block + assert_eq!(system.len(), 1); + + // Messages: user, assistant(text+2 tool_use), user(2 tool_results + follow-up text merged) + // The follow-up user message "Thanks!" merges into the tool_results User message + // because Bedrock requires strict user/assistant alternation. + assert_eq!(msgs.len(), 3); + + // msg[0]: user "What's the weather..." + assert_eq!(*msgs[0].role(), ConversationRole::User); + assert_eq!(msgs[0].content().len(), 1); + assert!(msgs[0].content()[0].is_text()); + + // msg[1]: assistant with text + 2 tool_use blocks + assert_eq!(*msgs[1].role(), ConversationRole::Assistant); + assert_eq!(msgs[1].content().len(), 3); // text + 2 tool_use + assert!(msgs[1].content()[0].is_text()); + assert!(msgs[1].content()[1].is_tool_use()); + assert!(msgs[1].content()[2].is_tool_use()); + + // Verify tool_use IDs and arguments survived conversion + let tu1 = msgs[1].content()[1].as_tool_use().unwrap(); + assert_eq!(tu1.tool_use_id(), "call_abc"); + assert_eq!(tu1.name(), "get_weather"); + let args1 = document_to_json(tu1.input()); + assert_eq!(args1, serde_json::json!({"city": "NYC"})); + + let tu2 = msgs[1].content()[2].as_tool_use().unwrap(); + assert_eq!(tu2.tool_use_id(), "call_def"); + assert_eq!(tu2.name(), "get_time"); + + // msg[2]: user with 2 tool_result blocks + merged follow-up text + // Tool results are User-role, and "Thanks!" is also User-role, so they merge. + assert_eq!(*msgs[2].role(), ConversationRole::User); + assert_eq!(msgs[2].content().len(), 3); // 2 tool_results + 1 text + assert!(msgs[2].content()[0].is_tool_result()); + assert!(msgs[2].content()[1].is_tool_result()); + assert!(msgs[2].content()[2].is_text()); + + // Verify tool_result IDs and content + let tr1 = msgs[2].content()[0].as_tool_result().unwrap(); + assert_eq!(tr1.tool_use_id(), "call_abc"); + assert_eq!(tr1.content().len(), 1); + + let tr2 = msgs[2].content()[1].as_tool_result().unwrap(); + assert_eq!(tr2.tool_use_id(), "call_def"); + } + + #[test] + fn test_convert_messages_empty_input() { + let (system, msgs) = convert_messages(&[]).unwrap(); + assert!(system.is_empty()); + assert!(msgs.is_empty()); + } + + #[test] + fn test_convert_messages_system_only() { + let messages = vec![ChatMessage::system("You are helpful.")]; + let (system, msgs) = convert_messages(&messages).unwrap(); + assert_eq!(system.len(), 1); + assert!(msgs.is_empty()); + } + + #[test] + fn test_build_tool_config_required_choice() { + let tools = vec![ToolDefinition { + name: "echo".to_string(), + description: "Echoes".to_string(), + parameters: serde_json::json!({"type": "object"}), + }]; + + let result = build_tool_config(&tools, Some("required")).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn test_map_stop_reason_all_variants() { + assert_eq!( + map_stop_reason(&StopReason::GuardrailIntervened), + FinishReason::ContentFilter + ); + assert_eq!( + map_stop_reason(&StopReason::ModelContextWindowExceeded), + FinishReason::Length + ); + } + + #[test] + fn test_build_inference_config_none_none() { + assert!(build_inference_config(None, None).is_none()); + } + + #[test] + fn test_build_inference_config_temperature_only() { + let config = build_inference_config(Some(0.7), None); + assert!(config.is_some()); + } + + #[test] + fn test_build_inference_config_max_tokens_only() { + let config = build_inference_config(None, Some(1024)); + assert!(config.is_some()); + } + + #[test] + fn test_build_inference_config_both() { + let config = build_inference_config(Some(0.5), Some(2048)); + assert!(config.is_some()); + } + + #[test] + fn test_build_inference_config_max_tokens_overflow() { + // u32::MAX exceeds i32::MAX, should clamp to i32::MAX not wrap + let config = build_inference_config(None, Some(u32::MAX)).unwrap(); + // Just verify it builds without panic β€” the clamped value is inside the opaque struct + let _ = config; + } + + #[test] + fn test_empty_messages_returns_error() { + let messages = vec![ChatMessage::system("System only, no user messages")]; + let (_, bedrock_msgs) = convert_messages(&messages).unwrap(); + assert!(bedrock_msgs.is_empty()); + } +} diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 724f89f69cd..38d8a99ad1e 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -6,7 +6,10 @@ //! - **Anthropic**: Direct API access with your own key //! - **Ollama**: Local model inference //! - **OpenAI-compatible**: Any endpoint that speaks the OpenAI API +//! - **AWS Bedrock**: Native Converse API via aws-sdk-bedrockruntime +#[cfg(feature = "bedrock")] +mod bedrock; pub mod circuit_breaker; pub mod costs; pub mod failover; @@ -60,6 +63,13 @@ pub fn create_llm_provider( LlmBackend::Ollama => create_ollama_provider(config), LlmBackend::OpenAiCompatible => create_openai_compatible_provider(config), LlmBackend::Tinfoil => create_tinfoil_provider(config), + #[cfg(feature = "bedrock")] + LlmBackend::Bedrock => create_bedrock_provider(config), + #[cfg(not(feature = "bedrock"))] + LlmBackend::Bedrock => Err(LlmError::RequestFailed { + provider: "bedrock".to_string(), + reason: "Bedrock support not compiled. Rebuild with --features bedrock".to_string(), + }), } } @@ -210,6 +220,24 @@ fn create_tinfoil_provider(config: &LlmConfig) -> Result, L Ok(Arc::new(RigAdapter::new(model, &tf.model))) } +#[cfg(feature = "bedrock")] +fn create_bedrock_provider(config: &LlmConfig) -> Result, LlmError> { + let br = config + .bedrock + .as_ref() + .ok_or_else(|| LlmError::AuthFailed { + provider: "bedrock".to_string(), + })?; + + let provider = bedrock::BedrockProvider::new(br)?; + tracing::info!( + "Using AWS Bedrock (Converse API, region: {}, model: {})", + br.region, + provider.active_model_name(), + ); + Ok(Arc::new(provider)) +} + fn create_openai_compatible_provider(config: &LlmConfig) -> Result, LlmError> { let compat = config .openai_compatible @@ -472,6 +500,7 @@ mod tests { ollama: None, openai_compatible: None, tinfoil: None, + bedrock: None, } } diff --git a/src/settings.rs b/src/settings.rs index 0e4b1fd99b9..9f934dd0f5a 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -43,7 +43,7 @@ pub struct Settings { pub secrets_master_key_source: KeySource, // === Step 3: Inference Provider === - /// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible". + /// LLM backend: "nearai", "anthropic", "openai", "ollama", "openai_compatible", "tinfoil", "bedrock". #[serde(default)] pub llm_backend: Option, @@ -55,6 +55,18 @@ pub struct Settings { #[serde(default)] pub openai_compatible_base_url: Option, + /// Bedrock region (when llm_backend = "bedrock"). + #[serde(default)] + pub bedrock_region: Option, + + /// Bedrock cross-region inference prefix (when llm_backend = "bedrock"). + #[serde(default)] + pub bedrock_cross_region: Option, + + /// AWS profile name for Bedrock (when llm_backend = "bedrock"). + #[serde(default)] + pub bedrock_profile: Option, + // === Step 4: Model Selection === /// Currently selected model. #[serde(default)] diff --git a/src/setup/README.md b/src/setup/README.md index c956529a648..c574e513e5e 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -174,6 +174,7 @@ env-var mode or skipped secrets. | Ollama | None | - | - | | OpenRouterΒΉ | API key | `llm_compatible_api_key` | `LLM_API_KEY` | | OpenAI-compatibleΒΉ | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` | +| AWS Bedrock | API key or AWS credentials | `bedrock_api_key` | `AWS_BEARER_TOKEN_BEDROCK` | ΒΉ OpenRouter and OpenAI-compatible share the same secret name and env var because OpenRouter is stored as `llm_backend = "openai_compatible"` under the hood. @@ -479,7 +480,7 @@ pub struct Settings { pub secrets_master_key_source: KeySource, // Keychain | Env | None // Step 3: Inference - pub llm_backend: Option, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" + pub llm_backend: Option, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "bedrock" pub ollama_base_url: Option, pub openai_compatible_base_url: Option, diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index b31a94f5fb0..ab42e4cde96 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -799,6 +799,7 @@ impl SetupWizard { "openai" => "OpenAI", "ollama" => "Ollama (local)", "openai_compatible" => "OpenAI-compatible endpoint", + "bedrock" => "AWS Bedrock", other => other, } }; @@ -807,7 +808,7 @@ impl SetupWizard { let is_known = matches!( current.as_str(), - "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" + "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible" | "bedrock" ); if is_known && confirm("Keep current provider?", true).map_err(SetupError::Io)? { @@ -821,6 +822,7 @@ impl SetupWizard { "openai" => return self.setup_openai().await, "ollama" => return self.setup_ollama(), "openai_compatible" => return self.setup_openai_compatible().await, + "bedrock" => return self.setup_bedrock().await, _ => { return Err(SetupError::Config(format!( "Unhandled provider: {}", @@ -848,6 +850,7 @@ impl SetupWizard { "Ollama - local models, no API key needed", "OpenRouter - 200+ models via single API key", "OpenAI-compatible - custom endpoint (vLLM, LiteLLM, etc.)", + "AWS Bedrock - Claude & other models via AWS (API key, IAM, SSO)", ]; let choice = select_one("Provider:", options).map_err(SetupError::Io)?; @@ -859,6 +862,7 @@ impl SetupWizard { 3 => self.setup_ollama()?, 4 => self.setup_openrouter().await?, 5 => self.setup_openai_compatible().await?, + 6 => self.setup_bedrock().await?, _ => return Err(SetupError::Config("Invalid provider selection".to_string())), } @@ -1062,6 +1066,142 @@ impl SetupWizard { .await } + /// AWS Bedrock provider setup: region, auth, and cross-region config. + async fn setup_bedrock(&mut self) -> Result<(), SetupError> { + self.settings.llm_backend = Some("bedrock".to_string()); + if self.settings.selected_model.is_some() { + self.settings.selected_model = None; + } + + // Region + let default_region = self + .settings + .bedrock_region + .as_deref() + .unwrap_or("us-east-1"); + + let region_input = + optional_input("AWS region", Some(&format!("default: {}", default_region))) + .map_err(SetupError::Io)?; + + let region = region_input.unwrap_or_else(|| default_region.to_string()); + self.settings.bedrock_region = Some(region.clone()); + + // Auth method + print_info("Select authentication method:"); + println!(); + let auth_options = &[ + "Bedrock API key (bearer token, simplest)", + "AWS default credentials (env vars, ~/.aws/credentials, IAM roles)", + "AWS named profile (SSO / assume-role)", + ]; + let auth_choice = select_one("Auth:", auth_options).map_err(SetupError::Io)?; + + match auth_choice { + 0 => { + // Bedrock API key (bearer token) + // Check env var first + if let Ok(existing) = std::env::var("AWS_BEARER_TOKEN_BEDROCK") + && !existing.is_empty() + { + print_info(&format!( + "AWS_BEARER_TOKEN_BEDROCK found: {}", + mask_api_key(&existing) + )); + if confirm("Use this key?", true).map_err(SetupError::Io)? { + // Persist to secrets store for future runs + if let Ok(ctx) = self.init_secrets_context().await { + let key = SecretString::from(existing); + if let Err(e) = ctx.save_secret("bedrock_api_key", &key).await { + tracing::warn!( + "Failed to persist Bedrock API key to secrets: {}", + e + ); + } + } + print_success("AWS Bedrock configured (from env)"); + // Skip to cross-region + return self.setup_bedrock_cross_region(); + } + } + + println!(); + print_info("Get your API key from: AWS Console β†’ Bedrock β†’ API keys"); + println!(); + + let key = secret_input("Bedrock API key").map_err(SetupError::Io)?; + let key_str = key.expose_secret(); + + if key_str.is_empty() { + return Err(SetupError::Config("API key cannot be empty".to_string())); + } + + if let Ok(ctx) = self.init_secrets_context().await { + ctx.save_secret("bedrock_api_key", &key) + .await + .map_err(|e| { + SetupError::Config(format!("Failed to save API key: {}", e)) + })?; + print_success("API key encrypted and saved"); + } else { + print_info( + "Secrets not available. Set AWS_BEARER_TOKEN_BEDROCK in your environment.", + ); + } + } + 1 => { + // Default AWS credentials + print_info( + "Using default AWS credential chain (env vars, ~/.aws/credentials, IAM roles).", + ); + } + 2 => { + // Named profile + let profile = + input("AWS profile name (from ~/.aws/config)").map_err(SetupError::Io)?; + if !profile.is_empty() { + self.settings.bedrock_profile = Some(profile.clone()); + print_success(&format!("AWS profile '{}' saved", profile)); + } + } + _ => return Err(SetupError::Config("Invalid auth selection".to_string())), + } + + self.setup_bedrock_cross_region() + } + + /// Bedrock cross-region inference prefix selection (sub-step of setup_bedrock). + fn setup_bedrock_cross_region(&mut self) -> Result<(), SetupError> { + print_info("Cross-region inference routes requests across AWS regions for capacity:"); + println!(); + let cross_options = &[ + "us - route within US regions (recommended for us-east-1)", + "global - route to any AWS region worldwide", + "eu - route within European regions", + "apac - route within Asia-Pacific regions", + "none - single-region only (no cross-region routing)", + ]; + let cross_choice = select_one("Cross-region:", cross_options).map_err(SetupError::Io)?; + + let cross_region = match cross_choice { + 0 => Some("us".to_string()), + 1 => Some("global".to_string()), + 2 => Some("eu".to_string()), + 3 => Some("apac".to_string()), + 4 => None, + _ => None, + }; + self.settings.bedrock_cross_region = cross_region; + + let region = self + .settings + .bedrock_region + .as_deref() + .unwrap_or("us-east-1"); + print_success(&format!("AWS Bedrock configured (region: {})", region)); + Ok(()) + } + /// OpenAI-compatible provider setup: base URL + optional API key. async fn setup_openai_compatible(&mut self) -> Result<(), SetupError> { self.settings.llm_backend = Some("openai_compatible".to_string()); @@ -1175,6 +1315,15 @@ impl SetupWizard { self.settings.selected_model = Some(model_id.clone()); print_success(&format!("Selected {}", model_id)); } + "bedrock" => { + let model_id = input("Bedrock model ID (e.g., anthropic.claude-opus-4-6-v1)") + .map_err(SetupError::Io)?; + if model_id.is_empty() { + return Err(SetupError::Config("Model ID is required".to_string())); + } + self.settings.selected_model = Some(model_id.clone()); + print_success(&format!("Selected {}", model_id)); + } _ => { // NEAR AI: use existing provider list_models() let fetched = self.fetch_nearai_models().await; @@ -1278,6 +1427,7 @@ impl SetupWizard { ollama: None, openai_compatible: None, tinfoil: None, + bedrock: None, }; match create_llm_provider(&config, session) { @@ -2036,6 +2186,20 @@ impl SetupWizard { if let Some(ref url) = self.settings.ollama_base_url { env_vars.push(("OLLAMA_BASE_URL", url.clone())); } + if let Some(ref region) = self.settings.bedrock_region { + env_vars.push(("BEDROCK_REGION", region.clone())); + } + if self.settings.llm_backend.as_deref() == Some("bedrock") { + if let Some(ref model) = self.settings.selected_model { + env_vars.push(("BEDROCK_MODEL", model.clone())); + } + if let Some(ref cross) = self.settings.bedrock_cross_region { + env_vars.push(("BEDROCK_CROSS_REGION", cross.clone())); + } + if let Some(ref profile) = self.settings.bedrock_profile { + env_vars.push(("AWS_PROFILE", profile.clone())); + } + } // Model name: same chicken-and-egg β€” Config::from_env() resolves the // model before the DB is connected, so we must persist it to .env. @@ -2297,6 +2461,7 @@ impl SetupWizard { "openai" => "OpenAI", "ollama" => "Ollama", "openai_compatible" => "OpenAI-compatible", + "bedrock" => "AWS Bedrock", other => other, }; println!(" Provider: {}", display);