diff --git a/.github/workflows/build-vendor-so.yml b/.github/workflows/build-vendor-so.yml new file mode 100644 index 0000000000..04a1959703 --- /dev/null +++ b/.github/workflows/build-vendor-so.yml @@ -0,0 +1,124 @@ +# One-off, manually-triggered build of the vendored `baml_py` native extension +# for hermetic monorepo consumers (e.g. a Blaze/Forge sandbox with an old GLIBC +# and no libgcc_s.so.1). +# +# Produces an x86_64 `.so` that is: +# - built on manylinux2014 (GLIBC 2.17), so it runs on old glibc sandboxes; +# - statically linked against libgcc/libstdc++ (no libgcc_s.so.1 at runtime); +# - built with vendored OpenSSL (native-tls-vendored), so no system libssl; +# - the minimal vendor profile: --no-default-features --features vertex. +# +# Trigger from the Actions tab ("Build vendored baml_py .so" -> Run workflow), +# then download the `baml_py-vendor-so` artifact and drop `baml_py.so` into +# third_party. +name: Build vendored baml_py .so + +on: + # Manual trigger (only works once this workflow is on the default branch). + workflow_dispatch: + inputs: + features: + description: "Cargo features (comma-separated, added on top of --no-default-features)" + required: true + default: "vertex,native-tls-vendored" + # Tag trigger: works from any branch without the default-branch requirement. + # To run a one-off: `git tag vendor-so-1 && git push origin vendor-so-1`. + push: + tags: + - "vendor-so-*" + +permissions: + contents: read + +jobs: + build: + name: x86_64 manylinux2014 static-libgcc + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.8" + + - name: Inject -static-libgcc into [build].rustflags (runner-local) + run: | + set -euo pipefail + cfg=engine/.cargo/config.toml + # maturin reads [build].rustflags and encodes them into + # CARGO_ENCODED_RUSTFLAGS, which overrides RUSTFLAGS / + # CARGO_BUILD_RUSTFLAGS / target-specific config. So the only reliable + # way to add a link flag is via [build].rustflags itself. We only edit + # the ephemeral CI checkout here (never committed), so mac/windows + # release builds are unaffected. -static-libgcc drops libgcc_s.so.1. + python3 - "$cfg" <<'PY' + import sys + p = sys.argv[1] + s = open(p).read() + old = 'rustflags = ["--cfg", "tracing_unstable"]' + new = 'rustflags = ["--cfg", "tracing_unstable", "-C", "link-arg=-static-libgcc"]' + assert old in s, "expected [build].rustflags line not found" + open(p, "w").write(s.replace(old, new, 1)) + print("patched:", new) + PY + grep -A2 '^\[build\]' "$cfg" + + - name: Build wheel (manylinux2014, static libgcc, vendored OpenSSL) + uses: PyO3/maturin-action@v1 + with: + target: x86_64-unknown-linux-gnu + command: build + # Build from engine/ so .cargo/config.toml is picked up. + working-directory: engine + manylinux: "2_17" + # Vendored OpenSSL is built from source, so the container needs perl. + before-script-linux: | + if command -v yum &> /dev/null; then + yum update -y && yum install -y perl-core pkgconfig libatomic + fi + args: >- + --release + --no-default-features + --features ${{ github.event.inputs.features || 'vertex,native-tls-vendored' }} + --out language_client_python/dist + --manifest-path language_client_python/Cargo.toml + + - name: Extract .so and verify hermeticity + working-directory: engine/language_client_python/dist + run: | + set -euo pipefail + # maturin-action builds inside a container as root, so dist/ is + # root-owned; take ownership before writing into it. + sudo chown -R "$(id -u):$(id -g)" . + whl=$(ls *.whl) + echo "Built wheel: $whl" + python -m zipfile -e "$whl" extracted/ + so=$(find extracted -name '*.so' | head -1) + cp "$so" baml_py.so + echo "::group::dynamic NEEDED libraries" + readelf -d baml_py.so | grep NEEDED || true + echo "::endgroup::" + echo "::group::max GLIBC symbol version" + maxglibc=$(objdump -T baml_py.so 2>/dev/null | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -1) + echo "$maxglibc" + echo "::endgroup::" + # Hard gates: fail the build if the artifact is not hermetic. + if readelf -d baml_py.so | grep -q 'libgcc_s'; then + echo "::error::baml_py.so still dynamically depends on libgcc_s.so.1" + exit 1 + fi + if [ "$(printf '%s\nGLIBC_2.17\n' "$maxglibc" | sort -V | tail -1)" != "GLIBC_2.17" ]; then + echo "::error::baml_py.so requires a GLIBC newer than 2.17 ($maxglibc)" + exit 1 + fi + echo "OK: no libgcc_s dependency, GLIBC <= 2.17" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: baml_py-vendor-so + path: | + engine/language_client_python/dist/baml_py.so + engine/language_client_python/dist/*.whl + if-no-files-found: error diff --git a/.github/workflows/primary.yml b/.github/workflows/primary.yml index 48b981992c..0fdacaf04a 100644 --- a/.github/workflows/primary.yml +++ b/.github/workflows/primary.yml @@ -226,6 +226,40 @@ jobs: run: | cd integ-tests/python && uv run ruff check . + # Guard the vendor profile (see engine/VENDORING.md): the minimal + # --no-default-features build that vendoring consumers use. + # Fails if the trimmed build breaks or if reqwest sneaks back into the + # native dependency tree. + vendor-profile: + runs-on: ubuntu-latest + needs: determine_changes + if: needs.determine_changes.outputs.runtime == 'true' + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Tools + uses: ./.github/actions/setup-tools + + - name: Setup Rust + uses: ./.github/actions/setup-rust + + - name: Check vendor profile builds + working-directory: engine + run: | + cargo check -p baml-python-ffi --no-default-features + cargo check -p baml-python-ffi --no-default-features --features vertex + cargo check -p baml-cli --no-default-features + + - name: Assert reqwest absent from vendor tree + working-directory: engine + run: | + if cargo tree -p baml-python-ffi --no-default-features -e normal | grep -q 'reqwest'; then + echo "::error::reqwest found in the vendor-profile dependency tree; shipping code must use baml-http (see engine/VENDORING.md)" + cargo tree -p baml-python-ffi --no-default-features -e normal -i reqwest | head -20 + exit 1 + fi + typecheck: runs-on: ubuntu-latest needs: determine_changes diff --git a/engine/Cargo.lock b/engine/Cargo.lock index 4abe51e011..b936b17852 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -211,114 +211,6 @@ dependencies = [ "wait-timeout", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.3.1", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - -[[package]] -name = "async-io" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3" -dependencies = [ - "async-lock", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.0.7", - "slab", - "tracing", - "windows-sys 0.59.0", -] - -[[package]] -name = "async-lock" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" -dependencies = [ - "event-listener 5.4.0", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-std" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers 0.3.0", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -341,12 +233,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.88" @@ -920,73 +806,41 @@ version = "0.223.0" dependencies = [ "anyhow", "assert_cmd", - "async-std", "axum 0.7.9", - "axum-extra", + "baml-http", "baml-log", "baml-runtime", "baml-types", "base64 0.22.1", "bstd", - "bytes", - "cfg-if", - "chrono", "clap", "clap-cargo", - "colored", "console", "console_log", "ctrlc", - "dashmap 5.5.3", "derive_more", "dialoguer", - "difference", "dissimilar", - "dunce", "either", - "enum_dispatch", - "env_logger", "etcetera", "expect-test", - "fastrand", "futures", "generators-lib", - "http 1.3.1", - "http-body 1.0.1", "indexmap 2.9.0", "indicatif", - "indicatif-log-bridge", "indoc", - "infer", "internal-baml-core", - "jsonwebtoken", "language-server", "log", - "mime", - "mime_guess", "open", "pathdiff 0.1.0", - "pretty_assertions", "rand 0.8.5", - "reqwest", "rstest", - "scopeguard", "serde", "serde_json", "sha2", - "shell-escape", "similar", - "static_assertions", - "strsim", - "strum", - "strum_macros", - "test-log", "tokio", - "tokio-stream", - "tracing", - "tracing-subscriber", - "url", - "uuid", "walkdir", "wasm-bindgen-test", "wasm-logger", @@ -998,6 +852,7 @@ name = "baml-compiler" version = "0.223.0" dependencies = [ "anyhow", + "baml-http", "baml-types", "baml-viz-events", "baml-vm", @@ -1007,7 +862,6 @@ dependencies = [ "itertools 0.14.0", "log", "pretty", - "reqwest", "serde_json", "tokio", ] @@ -1021,6 +875,30 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "baml-http" +version = "0.223.0" +dependencies = [ + "bytes", + "futures", + "http 1.3.1", + "http-body-util", + "hyper 1.6.0", + "hyper-rustls 0.27.7", + "hyper-tls", + "hyper-util", + "native-tls", + "reqwest", + "rustls 0.23.28", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "tokio-native-tls", + "url", + "webpki-roots", +] + [[package]] name = "baml-ids" version = "0.0.1" @@ -1123,7 +1001,7 @@ dependencies = [ "once_cell", "pyo3", "pyo3-async-runtimes", - "pyo3-build-config 0.21.2", + "pyo3-build-config", "regex", "serde", "serde_json", @@ -1164,7 +1042,6 @@ dependencies = [ "serde", "serde_json", "serde_with", - "sha2", "static_assertions", "strsim", "strum", @@ -1184,7 +1061,6 @@ version = "0.223.0" dependencies = [ "anyhow", "assert_cmd", - "async-std", "aws-config", "aws-credential-types", "aws-runtime", @@ -1198,6 +1074,7 @@ dependencies = [ "axum 0.7.9", "axum-extra", "baml-compiler", + "baml-http", "baml-ids", "baml-log", "baml-rpc", @@ -1235,7 +1112,7 @@ dependencies = [ "hostname", "http 1.3.1", "http-body 1.0.1", - "include_dir", + "http-body-util", "indexmap 2.9.0", "indicatif", "indoc", @@ -1249,7 +1126,6 @@ dependencies = [ "js-sys", "json_comments", "jsonish", - "jsonwebtoken", "junit-report", "log", "log-once", @@ -1263,10 +1139,8 @@ dependencies = [ "pretty", "pretty_assertions", "ratatui", - "reedline", "regex", "reqwest", - "ring", "rstest", "scopeguard", "secrecy", @@ -1275,7 +1149,6 @@ dependencies = [ "serde-wasm-bindgen 0.6.5", "serde_json", "serial_test", - "sha2", "shell-escape", "similar", "static_assertions", @@ -1306,7 +1179,7 @@ dependencies = [ "wasmtimer", "web-sys", "web-time", - "which 6.0.3", + "which 7.0.3", ] [[package]] @@ -1498,6 +1371,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "basic-toml" version = "0.1.10" @@ -1538,9 +1417,6 @@ name = "bitflags" version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" -dependencies = [ - "serde", -] [[package]] name = "blake3" @@ -1564,19 +1440,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blocking" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" -dependencies = [ - "async-channel 2.3.1", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bon" version = "3.7.2" @@ -1854,9 +1717,9 @@ dependencies = [ [[package]] name = "clap-cargo" -version = "0.14.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2ea69cefa96b848b73ad516ad1d59a195cdf9263087d977f648a818c8b43e" +checksum = "383f21342a464d4af96e9a4cad22a0b4f2880d4a5b3bbf5c9654dd1d9a224ee4" dependencies = [ "anstyle", "clap", @@ -1992,15 +1855,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "condtype" version = "1.3.0" @@ -2082,16 +1936,16 @@ dependencies = [ ] [[package]] -name = "constant_time_eq" -version = "0.3.1" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "convert_case" -version = "0.4.0" +name = "constant_time_eq" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] name = "convert_case" @@ -2249,7 +2103,6 @@ dependencies = [ "mio 1.0.4", "parking_lot", "rustix 0.38.44", - "serde", "signal-hook", "signal-hook-mio", "winapi", @@ -2399,6 +2252,17 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.3" @@ -2464,14 +2328,21 @@ dependencies = [ [[package]] name = "derive_more" -version = "0.99.20" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "convert_case 0.4.0", "proc-macro2", "quote", - "rustc_version", "syn 2.0.104", ] @@ -2494,12 +2365,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "difference" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" - [[package]] name = "difflib" version = "0.4.0" @@ -2513,6 +2378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -2705,6 +2571,12 @@ dependencies = [ "regex", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.11.8" @@ -2745,33 +2617,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener 5.4.0", - "pin-project-lite", -] - [[package]] name = "expect-test" version = "1.5.1" @@ -2788,17 +2633,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.0.7", - "windows-sys 0.59.0", -] - [[package]] name = "file-id" version = "0.2.2" @@ -2854,6 +2688,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2920,19 +2769,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-lite" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.31" @@ -2962,7 +2798,7 @@ version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" dependencies = [ - "gloo-timers 0.2.6", + "gloo-timers", "send_wrapper 0.4.0", ] @@ -2987,8 +2823,6 @@ dependencies = [ [[package]] name = "gcp_auth" version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf67f30198e045a039264c01fb44659ce82402d7771c50938beb41a5ac87733" dependencies = [ "async-trait", "base64 0.22.1", @@ -2998,14 +2832,17 @@ dependencies = [ "http 1.3.1", "http-body-util", "hyper 1.6.0", - "hyper-rustls 0.27.7", + "hyper-tls", "hyper-util", - "ring", + "native-tls", + "rsa", "rustls-pemfile 2.2.0", "serde", "serde_json", + "sha2", "thiserror 1.0.69", "tokio", + "tokio-native-tls", "tracing", "tracing-futures", "url", @@ -3196,18 +3033,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "gloo-utils" version = "0.2.0" @@ -3625,6 +3450,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.14" @@ -3802,25 +3643,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - [[package]] name = "index-map" version = "0.1.0" @@ -3862,16 +3684,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "indicatif-log-bridge" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63703cf9069b85dbe6fe26e1c5230d013dee99d3559cd3d02ba39e099ef7ab02" -dependencies = [ - "indicatif", - "log", -] - [[package]] name = "indoc" version = "2.0.6" @@ -3952,7 +3764,6 @@ dependencies = [ "regex", "serde", "serde_json", - "test-log", "unindent", ] @@ -3995,7 +3806,6 @@ dependencies = [ "strsim", "strum", "textwrap", - "whoami", ] [[package]] @@ -4189,15 +3999,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4321,21 +4122,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "junit-report" version = "0.8.3" @@ -4368,15 +4154,6 @@ dependencies = [ "libc", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "language-server" version = "0.223.0" @@ -4442,6 +4219,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -4469,6 +4249,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.3" @@ -4522,9 +4308,6 @@ name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" -dependencies = [ - "value-bag", -] [[package]] name = "log-once" @@ -4618,15 +4401,6 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "mime" version = "0.3.17" @@ -4767,7 +4541,7 @@ version = "3.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ba572deef53e2c386759a8c2014175a62679d74ff83adc205c8bc0e0285727" dependencies = [ - "convert_case 0.11.0", + "convert_case", "ctor", "napi-derive-backend", "proc-macro2", @@ -4781,7 +4555,7 @@ version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd961eb2aa8965e3f29722d754f3a86907eb1984e2fbcbe3fe87b9a02d6bfba" dependencies = [ - "convert_case 0.11.0", + "convert_case", "proc-macro2", "quote", "semver", @@ -4797,6 +4571,23 @@ dependencies = [ "libloading 0.9.0", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -4874,15 +4665,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "num" version = "0.4.3" @@ -4907,6 +4689,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -4960,6 +4758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -5040,12 +4839,59 @@ dependencies = [ "pathdiff 0.2.3", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -5094,12 +4940,6 @@ version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.4" @@ -5169,13 +5009,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29c4e8e2dd832fd76346468f822e4e600d30ba4e5aa545a128abf12cfae7ea3e" [[package]] -name = "pem" -version = "3.0.5" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" dependencies = [ - "base64 0.22.1", - "serde", + "base64ct", ] [[package]] @@ -5281,16 +5120,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "piper" -version = "0.2.4" +name = "pkcs1" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "playground-server" version = "0.223.0" @@ -5369,21 +5224,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "polling" -version = "3.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.0.7", - "tracing", - "windows-sys 0.59.0", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -5699,32 +5539,29 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.23.5" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" dependencies = [ - "cfg-if", "either", "indexmap 2.9.0", - "indoc", "libc", - "memoffset", "once_cell", "portable-atomic", - "pyo3-build-config 0.23.5", + "pyo3-build-config", "pyo3-ffi", "pyo3-macros", "serde", - "unindent", ] [[package]] name = "pyo3-async-runtimes" -version = "0.23.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977dc837525cfd22919ba6a831413854beb7c99a256c03bf8624ad707e45810e" +checksum = "9e7364a95bf00e8377bbf9b0f09d7ff9715a29d8fcf93b47d1a967363b973178" dependencies = [ - "futures", + "futures-channel", + "futures-util", "once_cell", "pin-project-lite", "pyo3", @@ -5734,9 +5571,9 @@ dependencies = [ [[package]] name = "pyo3-async-runtimes-macros" -version = "0.23.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2df2884957d2476731f987673befac5d521dff10abb0a7cbe12015bc7702fe9" +checksum = "c23399970eea9c31d0ac84cee4a9d8dd05f89b1da2f4dd5bb44b32a3f66db4f8" dependencies = [ "proc-macro2", "quote", @@ -5745,40 +5582,29 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883df5835fafdad87c0d888b266c8ec0f4c9ca48a5bed6bbb592e8dedee1b50" -dependencies = [ - "once_cell", - "target-lexicon", -] - -[[package]] -name = "pyo3-build-config" -version = "0.23.5" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ - "once_cell", "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.23.5" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" dependencies = [ "libc", - "pyo3-build-config 0.23.5", + "pyo3-build-config", ] [[package]] name = "pyo3-macros" -version = "0.23.5" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -5788,13 +5614,13 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.23.5" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ "heck 0.5.0", "proc-macro2", - "pyo3-build-config 0.23.5", + "pyo3-build-config", "quote", "syn 2.0.104", ] @@ -6030,26 +5856,6 @@ dependencies = [ "thiserror 2.0.12", ] -[[package]] -name = "reedline" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9834765acaa2deedf29197f2da3d309801683beb20e25775bc0249f97a7b9a74" -dependencies = [ - "chrono", - "crossterm", - "fd-lock", - "itertools 0.12.1", - "nu-ansi-term 0.50.1", - "serde", - "strip-ansi-escapes", - "strum", - "strum_macros", - "thiserror 1.0.69", - "unicode-segmentation", - "unicode-width 0.1.14", -] - [[package]] name = "ref-cast" version = "1.0.24" @@ -6184,6 +5990,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rstest" version = "0.22.0" @@ -6831,6 +6658,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -6854,18 +6691,6 @@ dependencies = [ "thread-id 3.3.0", ] -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.12", - "time", -] - [[package]] name = "slab" version = "0.4.10" @@ -6900,6 +6725,16 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -7035,9 +6870,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.12.16" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -7304,6 +7139,16 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -7641,7 +7486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", - "nu-ansi-term 0.46.0", + "nu-ansi-term", "once_cell", "regex", "serde", @@ -7926,10 +7771,10 @@ dependencies = [ ] [[package]] -name = "value-bag" -version = "1.11.1" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" @@ -7995,12 +7840,6 @@ dependencies = [ "wit-bindgen-rt", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.105" @@ -8197,27 +8036,16 @@ dependencies = [ [[package]] name = "which" -version = "6.0.3" +version = "7.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", - "home", - "rustix 0.38.44", + "env_home", + "rustix 1.0.7", "winsafe", ] -[[package]] -name = "whoami" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" -dependencies = [ - "redox_syscall 0.5.13", - "wasite", - "web-sys", -] - [[package]] name = "winapi" version = "0.3.9" diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 8c3bc9e956..cc403aeb17 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "baml-compiler", + "baml-http", "baml-viz-events", "baml-lib/*", "baml-lsp-types", @@ -24,15 +25,21 @@ members = [ "sandbox", "boundary-udf", "playground-server", + # A modified fork we own (ring-free TLS + signer); a workspace member so our + # changes get fmt/clippy/test coverage in CI. Its one network test is + # #[ignore]d, so `cargo test --workspace` stays hermetic. + "vendored/gcp-auth", ] -# Vendored third-party crates: consumed as path dependencies but kept out of the -# workspace so their dev-deps/tests don't enter our build graph. +# minijinja is a large upstream fork tracked close to upstream; kept out of the +# workspace so its extensive test suite and dev-deps don't enter our build +# graph. (It is still consumed as a path dependency.) exclude = [ "vendored/minijinja", "vendored/minijinja-contrib", ] default-members = [ "baml-compiler", + "baml-http", "baml-lsp-types", "baml-lib/*", "baml-rpc", @@ -61,6 +68,7 @@ askama = { version = "0.14.0", features = ["code-in-doc"] } axum = "0.7.5" baml-cli = { path = "cli" } baml-derive = { path = "baml-lib/baml-derive" } +baml-http = { path = "baml-http" } baml-ids = { path = "baml-ids" } baml-rpc = { path = "baml-rpc" } baml-types = { path = "baml-lib/baml-types" } @@ -77,7 +85,9 @@ console_log = "1" dashmap = "5.5.3" derive-new = "0.7" derive_builder = "0.20.0" -derive_more = { version = "0.99.18", features = ["constructor"] } +# 1.0 requires each derive's feature to be listed explicitly (0.99 enabled all +# by default). The workspace uses only `From` and `Constructor`. +derive_more = { version = "1.0", features = ["from", "constructor"] } dissimilar = "1.0.9" dunce = "1.0.4" either = "1.8.1" @@ -177,6 +187,9 @@ web-sys = { version = "0.3.78", features = [ "Crypto", "CryptoKey", "Headers", + # Navigator was previously enabled transitively (async-std's wasm backend); + # declare it explicitly since baml-schema-wasm calls window.navigator(). + "Navigator", "Request", "RequestInit", "Response", diff --git a/engine/VENDORING.md b/engine/VENDORING.md new file mode 100644 index 0000000000..d20ea5cdc1 --- /dev/null +++ b/engine/VENDORING.md @@ -0,0 +1,170 @@ +# Vendoring the BAML engine + +This workspace supports a **vendor profile**: a minimal, feature-gated build of +the Python bindings (`baml_py`) and the `baml` CLI for environments that vendor +all third-party code (typically large monorepos where every external crate must +be imported and reviewed). + +## The vendor profile + +```bash +# The Python extension module (includes the CLI entrypoints): +cargo build -p baml-python-ffi --no-default-features --release + +# The standalone CLI, same trimmed feature set: +cargo build -p baml-cli --no-default-features --release +``` + +`--no-default-features` on either crate disables, relative to the published +artifacts: + +| Feature | What it gates | Heavy deps dropped | +| --------- | ----------------------------------------- | ----------------------------------------------- | +| `lsp` | `baml-cli lsp` (the language server) | lsp-server/lsp-types, tonic+prost, axum 0.8, tokio-tungstenite, schemars, tar, console-subscriber | +| `bedrock` | AWS Bedrock provider | the entire `aws-*` / smithy SDK tree | +| `serve` | `baml serve` (local OpenAPI server) | axum 0.7, axum-extra | +| `dev` | `baml dev` (serve + file watching) | notify, notify-debouncer-full | +| `repl` | `baml repl` | dirs, supports-color | +| `cloud` | `baml auth/login/deploy` (Boundary Cloud) | dialoguer, indicatif(-cli), open, jsonwebtoken-adjacent auth stack | +| `vertex` | Vertex AI (Google Cloud) provider auth | gcp_auth | +| `tui` | Interactive terminal UIs (`baml init` stepper) | ratatui, crossterm, cassowary, vte, … (~12 crates) | +| `optimize`| `baml optimize` (GEPA; TUI-driven) | (via `tui`) | +| `studio` | Boundary Studio tracing publisher | baml-rpc (ts-rs, serde_with), blake3, flate2 | + +What remains is `baml init / generate / check / test / fmt / dump-hir / +dump-bytecode` plus the full in-process runtime (OpenAI, Anthropic, +Google AI, and any OpenAI-compatible provider). Disabling `vertex` or +`bedrock` does not remove the provider from the language: using it simply +fails at request time with an error explaining the feature was compiled out. +Without `tui`, `baml init` uses its plain-text output path (same behavior as +`BAML_NO_UI=1`). + +Every feature is **on by default**, so published artifacts (PyPI, NPM, gems, +the release CLI) are unaffected by the gating. + +## Using Vertex AI + +Add the `vertex` feature back on top of the minimal profile: + +```bash +cargo build -p baml-python-ffi --no-default-features --features vertex --release +``` + +This adds 8 crates (~289 total): `gcp_auth` (hyper-based, so reqwest stays out +of the tree), `rustls-pemfile`, `async-trait`, `pin-project`(+internal), +`tracing-futures`, and a second `thiserror` (v1, alongside v2). + +Auth strategies supported by the vertex-ai provider (via gcp_auth): a service +account JSON file path or JSON string/object passed in client options, or +system default resolution (application-default credentials file → GCE +metadata server → gcloud CLI). `base_url` can be overridden per client if +requests must go through a proxy. + +## HTTP stack: baml-http (hyper), no reqwest + +All shipping HTTP goes through the internal [`baml-http`](baml-http/) crate: + +- **Native targets**: implemented directly on `hyper` + `hyper-util`, with a + selectable TLS backend (see below). reqwest is not in the native dependency + tree at all. +- **wasm32**: re-exports reqwest, whose browser-fetch backend is the only + practical option there. + +reqwest is still used by non-shipping/auxiliary code: the language-server's +CORS proxy (gated out of the vendor profile via `lsp`), the playground +server, sandbox, and tests. + +Known differences from the old reqwest stack (see `baml-http/src/lib.rs`): +environment proxies (`HTTP_PROXY`/`HTTPS_PROXY`) are not supported, and +`read_timeout` acts as an idle timeout between body chunks. + +### TLS backend (and the `ring` question) + +baml-http's TLS backend is a cargo feature: + +- **`native-tls` (default)**: platform TLS (SChannel / Security.framework / + system OpenSSL) via hyper-tls. Reads the OS trust store (corporate/internal + CAs work out of the box) and **does not pull in `ring`**. Uses HTTP/1.1. +- **`rustls-tls`**: statically-linked rustls + ring + bundled webpki roots, + HTTP/2 enabled. Most portable for prebuilt wheels, but pulls in `ring`. +- **`native-tls-vendored`**: native-tls with a statically-vendored OpenSSL, + for portable Linux wheels without a system `libssl`. + +With the default (`native-tls`), the vendor profile is **ring-free**, +including with `--features vertex`. + +Vertex uses a vendored, ring-free fork of `gcp_auth` (see +[`vendored/gcp-auth`](vendored/gcp-auth/)): upstream `gcp_auth 0.12` pulls +`ring` for both TLS (hyper-rustls) and JWT RS256 signing. The fork swaps TLS to +native-tls and the signer to the pure-Rust `rsa` crate. The signer is verified +byte-identical to `openssl dgst -sha256 -sign` (a unit test in +`vendored/gcp-auth/src/types.rs`). To use a different signing backend (e.g. +BoringSSL), replace the ~15-line `Signer` impl in that file. + +## Generating the import list + +The exact crate set to vendor (note `-e normal,build`: build-dependencies +like `cc` must be imported too): + +```bash +cargo tree -p baml-python-ffi --no-default-features -e normal,build --prefix none \ + | sed -E 's/ \(\*\)//; s/ \(proc-macro\)//' \ + | grep -v "$(pwd)" \ + | awk '{print $1" "$2}' | sort -u +``` + +As of this writing that is ~283 unique external crates (vs ~460+ for the +published wheel). The only crate with native/asm build requirements on Linux +is **`ring`** (rustls' crypto backend). `core-foundation-sys`, +`security-framework-sys`, and `fsevent-sys` in the list are macOS-only cfg +dependencies; `dirs-sys` is pure Rust. + +## Build-system notes for consumers + +- `baml-python-ffi` is a plain pyo3 `cdylib` using `abi3-py38` — no maturin + needed inside a monorepo build system; a `rust_shared_library` (or + equivalent pyo3 rule) plus a `py_library` wrapper suffices. +- `baml-cli` is an ordinary `rust_binary`. +- Vendor from release tags, and regenerate + diff the crate list on each + upgrade so new imports are an explicit, reviewable event. + +## Supply-chain audit coverage + +Several organizations publish cargo-vet audit sets; the largest public +aggregation is [google/rust-crate-audits](https://github.com/google/rust-crate-audits) +(Android, Fuchsia, gVisor, ChromeOS…). `scripts/vendor-audit-coverage.py` +cross-references the vendor profile against it. As of 2026-07-07: + +- **75 crates (25%)**: exact version covered by a published audit. +- **141 crates**: audited at a different (usually older) version; delta + review, typically cheap. +- **78 crates**: no published audit. The big-ticket items: + - **pyo3 family** (6 crates): the Python binding layer itself; + unsafe-heavy, unavoidable for `baml_py`, and the single largest review. + Note `pyo3-build-config`/`python3-dll-a` are build-time crates that read + the environment (they locate the Python toolchain). + - **rustls 0.23 family + webpki-roots**: older rustls lineages have + published audits; the 0.23 line does not yet. `webpki-roots` is + CDLA-Permissive-2.0 (the Mozilla CA bundle license); see the trust-roots + note above for avoiding it entirely. + - **askama** (+derive/parser): compile-time templates in the generators; + its derive macro reads template files from the source tree at build time, + which strict proc-macro policies will want to look at. + +Version multiplicity: 13 crates appear at 2 versions each (all different +semver epochs, e.g. `syn` 1+2, `hashbrown` 0.14+0.15). All but one +(`rustc-hash`, pinned in `internal-baml-parser-database`) come from external +pins. + +Licenses: overwhelmingly `MIT OR Apache-2.0`; the rest are +ISC/Unicode-3.0/Unlicense-OR-MIT notice licenses. The only unusual one is +`webpki-roots` (CDLA-Permissive-2.0, see above). + +## Keeping it green + +CI runs the vendor profile on every PR (`vendor-profile` job in +`.github/workflows/primary.yml`): the `--no-default-features` builds (with +and without `vertex`) must compile, and `reqwest` must not appear in the +native vendor tree. If your change adds a dependency to the vendor profile, +expect the reviewer to ask whether it can live behind one of the feature +gates above instead. diff --git a/engine/baml-compiler/Cargo.toml b/engine/baml-compiler/Cargo.toml index fe9b636afa..a75626697e 100644 --- a/engine/baml-compiler/Cargo.toml +++ b/engine/baml-compiler/Cargo.toml @@ -14,10 +14,10 @@ baml-viz-events = { path = "../baml-viz-events" } internal-baml-ast = { path = "../baml-lib/ast" } internal-baml-diagnostics = { path = "../baml-lib/diagnostics" } internal-baml-parser-database = { path = "../baml-lib/parser-database" } +baml-http = { workspace = true } itertools = { workspace = true } log = { workspace = true } pretty = { workspace = true } -reqwest = { workspace = true } serde_json = { workspace = true } [dev-dependencies] diff --git a/engine/baml-compiler/src/thir/interpret.rs b/engine/baml-compiler/src/thir/interpret.rs index d3e5eb3730..e32b358e07 100644 --- a/engine/baml-compiler/src/thir/interpret.rs +++ b/engine/baml-compiler/src/thir/interpret.rs @@ -2758,7 +2758,7 @@ async fn evaluate_builtin_function( let target_type = &type_args[0]; // Make HTTP request - let response = reqwest::get(&url).await.with_context(|| { + let response = baml_http::get(&url).await.with_context(|| { format!( "baml.fetch_as: failed to fetch URL '{}' at {:?}", url, meta.0 diff --git a/engine/baml-http/Cargo.toml b/engine/baml-http/Cargo.toml new file mode 100644 index 0000000000..20373026b0 --- /dev/null +++ b/engine/baml-http/Cargo.toml @@ -0,0 +1,81 @@ +[package] +name = "baml-http" +edition = "2021" +version.workspace = true +authors.workspace = true +description = "HTTP client facade for BAML: hyper-backed native, reqwest on wasm" +license = "Apache-2.0" + +[lints.rust] +elided_named_lifetimes = "deny" +unused_imports = "deny" +unused_must_use = "deny" +unused_variables = "deny" + +[features] +# TLS backend for the native (hyper) client. Exactly one should be enabled. +# +# native-tls (default): links the platform TLS (SChannel / Security.framework +# / system OpenSSL). Picks up the OS trust store automatically, so it works +# out of the box in environments with corporate/internal CAs, and avoids +# the `ring` crate entirely. This is the friendliest option for monorepos +# that ban ring and mandate the system/BoringSSL crypto. +# +# rustls-tls: statically links rustls + ring with bundled webpki roots. No +# system TLS dependency, which is the most portable option for prebuilt +# wheels, but pulls in `ring`. +default = ["native-tls"] +native-tls = ["dep:hyper-tls", "dep:native-tls", "dep:tokio-native-tls"] +# Build native-tls against a statically-vendored OpenSSL (portable wheels +# without a system libssl). Ignored on macOS/Windows, which use the OS stack. +native-tls-vendored = ["native-tls", "native-tls?/vendored"] +rustls-tls = ["dep:hyper-rustls", "dep:rustls", "dep:webpki-roots"] + +[dependencies] +bytes.workspace = true +futures.workspace = true +http.workspace = true +serde.workspace = true +serde_json.workspace = true +url.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hyper = { version = "1", default-features = false, features = [ + "client", + "http1", + "http2", +] } +hyper-util = { version = "0.1.14", default-features = false, features = [ + "client", + "client-legacy", + "http1", + "http2", + "tokio", +] } +# native-tls backend (default) +hyper-tls = { version = "0.6", optional = true } +native-tls = { version = "0.2", features = ["alpn"], optional = true } +tokio-native-tls = { version = "0.3", optional = true } +# rustls backend (opt-in) +hyper-rustls = { version = "0.27", default-features = false, features = [ + "http1", + "http2", + "webpki-roots", + "ring", + "tls12", + "native-tokio", +], optional = true } +rustls = { version = "0.23", default-features = false, features = [ + "ring", + "std", + "tls12", +], optional = true } +webpki-roots = { version = "1.0", optional = true } +http-body-util = { version = "0.1" } +serde_urlencoded = { version = "0.7" } +tokio = { version = "1", default-features = false, features = ["time"] } + +# On wasm32 the reqwest (browser fetch) backend is used; hyper cannot run in +# the browser. On native targets the hyper backend is the only implementation. +[target.'cfg(target_arch = "wasm32")'.dependencies] +reqwest.workspace = true diff --git a/engine/baml-http/src/hyper_client.rs b/engine/baml-http/src/hyper_client.rs new file mode 100644 index 0000000000..039fa5fca1 --- /dev/null +++ b/engine/baml-http/src/hyper_client.rs @@ -0,0 +1,977 @@ +//! hyper-backed implementation of the reqwest API subset BAML uses. +//! +//! Built on hyper + hyper-util (legacy pooling client) with a feature-selected +//! TLS backend (native-tls by default, rustls optional; see the crate docs). +//! Differences from reqwest, by design: +//! +//! - No environment proxy support (HTTP_PROXY/HTTPS_PROXY are ignored). +//! - `read_timeout` is applied as an idle-timeout between body chunks (and as +//! a total timeout for buffered body reads) rather than a socket read +//! timeout. +//! +//! Redirects follow reqwest's default policy: up to 10 hops; 301/302/303 +//! rewrite POST to GET and drop the body; 307/308 preserve method and body; +//! sensitive headers are stripped when the host changes. + +use std::{fmt, sync::Arc, time::Duration}; + +use bytes::Bytes; +pub use http::{header, Method, StatusCode}; +use http_body_util::{BodyExt, BodyStream, Full}; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client as LegacyClient}, + rt::{TokioExecutor, TokioTimer}, +}; +pub use url::Url; + +#[cfg(feature = "native-tls")] +type Connector = hyper_tls::HttpsConnector; +#[cfg(all(feature = "rustls-tls", not(feature = "native-tls")))] +type Connector = hyper_rustls::HttpsConnector; + +#[cfg(not(any(feature = "native-tls", feature = "rustls-tls")))] +compile_error!("baml-http: enable a TLS backend feature (`native-tls` (default) or `rustls-tls`)"); + +type Inner = LegacyClient>; + +const MAX_REDIRECTS: usize = 10; + +// --------------------------------------------------------------------------- +// Error +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Builder, + Connect, + Request, + Timeout, + Body, + Decode, + Redirect, + Status, +} + +/// Boxed internals (like reqwest's) so `Result` stays small. +struct ErrorInner { + kind: Kind, + url: Option, + status: Option, + source: Option>, + message: Option, +} + +pub struct Error { + inner: Box, +} + +pub type Result = std::result::Result; + +impl Error { + fn new(kind: Kind) -> Self { + Error { + inner: Box::new(ErrorInner { + kind, + url: None, + status: None, + source: None, + message: None, + }), + } + } + + fn with_url(mut self, url: Url) -> Self { + self.inner.url = Some(url); + self + } + + fn with_status(mut self, status: StatusCode) -> Self { + self.inner.status = Some(status); + self + } + + fn with_source(mut self, source: impl Into>) -> Self { + self.inner.source = Some(source.into()); + self + } + + fn with_message(mut self, message: impl Into) -> Self { + self.inner.message = Some(message.into()); + self + } + + pub fn is_timeout(&self) -> bool { + self.inner.kind == Kind::Timeout + } + + pub fn is_connect(&self) -> bool { + self.inner.kind == Kind::Connect + } + + pub fn is_request(&self) -> bool { + matches!( + self.inner.kind, + Kind::Request | Kind::Connect | Kind::Timeout + ) + } + + pub fn is_body(&self) -> bool { + self.inner.kind == Kind::Body + } + + pub fn is_decode(&self) -> bool { + self.inner.kind == Kind::Decode + } + + pub fn is_builder(&self) -> bool { + self.inner.kind == Kind::Builder + } + + pub fn is_redirect(&self) -> bool { + self.inner.kind == Kind::Redirect + } + + pub fn status(&self) -> Option { + self.inner.status + } + + pub fn url(&self) -> Option<&Url> { + self.inner.url.as_ref() + } +} + +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut b = f.debug_struct("baml_http::Error"); + b.field("kind", &self.inner.kind); + if let Some(url) = &self.inner.url { + b.field("url", url); + } + if let Some(status) = &self.inner.status { + b.field("status", status); + } + if let Some(message) = &self.inner.message { + b.field("message", message); + } + if let Some(source) = &self.inner.source { + b.field("source", source); + } + b.finish() + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.inner.kind { + Kind::Builder => write!(f, "builder error")?, + Kind::Connect => write!(f, "error trying to connect")?, + Kind::Request => write!(f, "error sending request")?, + // "operation timed out" matches reqwest's wording; some callers + // classify timeouts by string-matching the error text. + Kind::Timeout => write!(f, "operation timed out")?, + Kind::Body => write!(f, "error reading response body")?, + Kind::Decode => write!(f, "error decoding response body")?, + Kind::Redirect => write!(f, "error following redirect")?, + Kind::Status => match self.inner.status { + Some(status) if status.is_client_error() => { + write!(f, "HTTP status client error ({status})")? + } + Some(status) => write!(f, "HTTP status server error ({status})")?, + None => write!(f, "HTTP status error")?, + }, + } + if let Some(message) = &self.inner.message { + write!(f, ": {message}")?; + } + if let Some(url) = &self.inner.url { + write!(f, " for url ({url})")?; + } + if let Some(source) = &self.inner.source { + write!(f, ": {source}")?; + } + Ok(()) + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.inner + .source + .as_deref() + .map(|e| e as &(dyn std::error::Error + 'static)) + } +} + +fn classify_legacy_error(err: hyper_util::client::legacy::Error, url: &Url) -> Error { + let kind = if err.is_connect() { + Kind::Connect + } else { + Kind::Request + }; + Error::new(kind).with_url(url.clone()).with_source(err) +} + +// --------------------------------------------------------------------------- +// IntoUrl +// --------------------------------------------------------------------------- + +pub trait IntoUrl { + fn into_url(self) -> Result; +} + +impl IntoUrl for Url { + fn into_url(self) -> Result { + if self.has_host() { + Ok(self) + } else { + Err(Error::new(Kind::Builder) + .with_message(format!("URL scheme is not allowed: {self}"))) + } + } +} + +impl IntoUrl for &str { + fn into_url(self) -> Result { + Url::parse(self) + .map_err(|e| { + Error::new(Kind::Builder).with_message(format!("invalid URL {self:?}: {e}")) + }) + .and_then(IntoUrl::into_url) + } +} + +impl IntoUrl for &String { + fn into_url(self) -> Result { + self.as_str().into_url() + } +} + +impl IntoUrl for String { + fn into_url(self) -> Result { + self.as_str().into_url() + } +} + +// --------------------------------------------------------------------------- +// Body +// --------------------------------------------------------------------------- + +pub struct Body { + bytes: Bytes, +} + +impl Body { + pub fn as_bytes(&self) -> Option<&[u8]> { + Some(&self.bytes) + } +} + +impl From for Body { + fn from(bytes: Bytes) -> Self { + Body { bytes } + } +} + +impl From> for Body { + fn from(v: Vec) -> Self { + Body { bytes: v.into() } + } +} + +impl From for Body { + fn from(s: String) -> Self { + Body { bytes: s.into() } + } +} + +impl From<&'static str> for Body { + fn from(s: &'static str) -> Self { + Body { bytes: s.into() } + } +} + +impl From<&'static [u8]> for Body { + fn from(s: &'static [u8]) -> Self { + Body { bytes: s.into() } + } +} + +// --------------------------------------------------------------------------- +// TLS: accept-invalid-certs verifier (used for user-configured local proxies) +// --------------------------------------------------------------------------- + +#[cfg(all(feature = "rustls-tls", not(feature = "native-tls")))] +#[derive(Debug)] +struct DangerAcceptAnyCert(rustls::crypto::CryptoProvider); + +#[cfg(all(feature = "rustls-tls", not(feature = "native-tls")))] +impl rustls::client::danger::ServerCertVerifier for DangerAcceptAnyCert { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +// --------------------------------------------------------------------------- +// ClientBuilder / Client +// --------------------------------------------------------------------------- + +pub struct ClientBuilder { + connect_timeout: Option, + read_timeout: Option, + pool_idle_timeout: Option, + pool_max_idle_per_host: Option, + http2_keep_alive_interval: Option, + danger_accept_invalid_certs: bool, +} + +impl ClientBuilder { + pub fn new() -> Self { + ClientBuilder { + connect_timeout: None, + read_timeout: None, + pool_idle_timeout: Some(Duration::from_secs(90)), + pool_max_idle_per_host: None, + http2_keep_alive_interval: None, + danger_accept_invalid_certs: false, + } + } + + pub fn connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = Some(timeout); + self + } + + pub fn read_timeout(mut self, timeout: Duration) -> Self { + self.read_timeout = Some(timeout); + self + } + + pub fn pool_idle_timeout(mut self, timeout: impl Into>) -> Self { + self.pool_idle_timeout = timeout.into(); + self + } + + pub fn pool_max_idle_per_host(mut self, max: usize) -> Self { + self.pool_max_idle_per_host = Some(max); + self + } + + pub fn http2_keep_alive_interval(mut self, interval: impl Into>) -> Self { + self.http2_keep_alive_interval = interval.into(); + self + } + + pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self { + self.danger_accept_invalid_certs = accept; + self + } + + pub fn build(self) -> Result { + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_connect_timeout(self.connect_timeout); + + let https = self.build_https_connector(http)?; + + let mut builder = LegacyClient::builder(TokioExecutor::new()); + builder + .pool_timer(TokioTimer::new()) + .timer(TokioTimer::new()) + .pool_idle_timeout(self.pool_idle_timeout); + if let Some(max) = self.pool_max_idle_per_host { + builder.pool_max_idle_per_host(max); + } + if let Some(interval) = self.http2_keep_alive_interval { + builder.http2_keep_alive_interval(interval); + } + + Ok(Client { + inner: Arc::new(builder.build(https)), + read_timeout: self.read_timeout, + }) + } + + #[cfg(feature = "native-tls")] + fn build_https_connector(&self, http: HttpConnector) -> Result { + let mut tls = native_tls::TlsConnector::builder(); + // Force HTTP/1.1: hyper-tls does not forward the negotiated-h2 ALPN + // hint to hyper-util, so offering h2 makes the server speak h2 while + // hyper still sends HTTP/1.1 frames (hyper::Error(UnexpectedMessage)). + // LLM request/response and SSE streaming work fine over HTTP/1.1. + tls.request_alpns(&["http/1.1"]); + if self.danger_accept_invalid_certs { + tls.danger_accept_invalid_certs(true); + } + let tls = tls + .build() + .map_err(|e| Error::new(Kind::Builder).with_source(e))?; + let mut https = + hyper_tls::HttpsConnector::from((http, tokio_native_tls::TlsConnector::from(tls))); + // Permit plain-HTTP requests (e.g. user-configured local proxies), + // matching the rustls backend's `https_or_http()`. + https.https_only(false); + Ok(https) + } + + #[cfg(all(feature = "rustls-tls", not(feature = "native-tls")))] + fn build_https_connector(&self, http: HttpConnector) -> Result { + let provider = rustls::crypto::ring::default_provider(); + + let tls = if self.danger_accept_invalid_certs { + rustls::ClientConfig::builder_with_provider(Arc::new(provider.clone())) + .with_safe_default_protocol_versions() + .map_err(|e| Error::new(Kind::Builder).with_source(e))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(DangerAcceptAnyCert(provider))) + .with_no_client_auth() + } else { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder_with_provider(Arc::new(provider)) + .with_safe_default_protocol_versions() + .map_err(|e| Error::new(Kind::Builder).with_source(e))? + .with_root_certificates(roots) + .with_no_client_auth() + }; + // Note: ALPN is configured by hyper-rustls (enable_http1/enable_http2 + // below); pre-setting tls.alpn_protocols here would panic. + + Ok(hyper_rustls::HttpsConnectorBuilder::new() + .with_tls_config(tls) + .https_or_http() + .enable_http1() + .enable_http2() + .wrap_connector(http)) + } +} + +impl Default for ClientBuilder { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub struct Client { + inner: Arc, + read_timeout: Option, +} + +impl fmt::Debug for Client { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("baml_http::Client").finish_non_exhaustive() + } +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + +impl Client { + /// Panics on TLS setup failure, matching `reqwest::Client::new`. + pub fn new() -> Client { + ClientBuilder::new() + .build() + .expect("baml_http::Client::new: failed to initialize TLS backend") + } + + pub fn builder() -> ClientBuilder { + ClientBuilder::new() + } + + pub fn get(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::GET, url) + } + + pub fn post(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::POST, url) + } + + pub fn request(&self, method: Method, url: impl IntoUrl) -> RequestBuilder { + RequestBuilder { + client: self.clone(), + request: url.into_url().map(|url| Request { + method, + url, + headers: header::HeaderMap::new(), + body: None, + timeout: None, + }), + } + } + + pub fn head(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::HEAD, url) + } + + pub fn put(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::PUT, url) + } + + pub fn delete(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::DELETE, url) + } + + pub fn patch(&self, url: impl IntoUrl) -> RequestBuilder { + self.request(Method::PATCH, url) + } + + pub async fn execute(&self, request: Request) -> Result { + match request.timeout { + Some(timeout) => tokio::time::timeout(timeout, self.execute_inner(request)) + .await + .map_err(|_| Error::new(Kind::Timeout))?, + None => self.execute_inner(request).await, + } + } + + async fn execute_inner(&self, request: Request) -> Result { + let Request { + mut method, + mut url, + mut headers, + body, + timeout: _, + } = request; + let mut body = body.map(|b| b.bytes).unwrap_or_default(); + + for _hop in 0..=MAX_REDIRECTS { + let mut req = http::Request::builder() + .method(method.clone()) + .uri(url.as_str()) + .body(Full::new(body.clone())) + .map_err(|e| { + Error::new(Kind::Builder) + .with_url(url.clone()) + .with_source(e) + })?; + *req.headers_mut() = headers.clone(); + + let resp = self + .inner + .request(req) + .await + .map_err(|e| classify_legacy_error(e, &url))?; + + let status = resp.status(); + if !status.is_redirection() { + let (parts, incoming) = resp.into_parts(); + return Ok(Response { + status: parts.status, + headers: parts.headers, + url, + body: incoming, + read_timeout: self.read_timeout, + }); + } + + let Some(location) = resp + .headers() + .get(header::LOCATION) + .and_then(|l| l.to_str().ok()) + .and_then(|l| url.join(l).ok()) + else { + // Redirect status without a usable Location: return as-is. + let (parts, incoming) = resp.into_parts(); + return Ok(Response { + status: parts.status, + headers: parts.headers, + url, + body: incoming, + read_timeout: self.read_timeout, + }); + }; + + match status { + StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND | StatusCode::SEE_OTHER => { + // Per reqwest's policy these rewrite to GET and drop the body. + method = Method::GET; + body = Bytes::new(); + for h in [ + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_ENCODING, + ] { + headers.remove(h); + } + } + _ => {} // 307/308: preserve method and body + } + if location.host_str() != url.host_str() || location.port() != url.port() { + for h in [ + header::AUTHORIZATION, + header::COOKIE, + header::PROXY_AUTHORIZATION, + header::WWW_AUTHENTICATE, + ] { + headers.remove(h); + } + } + url = location; + } + + Err(Error::new(Kind::Redirect) + .with_url(url) + .with_message(format!("too many redirects (max {MAX_REDIRECTS})"))) + } +} + +/// Like `reqwest::get`: one-shot GET with a default client. +pub async fn get(url: impl IntoUrl) -> Result { + Client::new().get(url).send().await +} + +// --------------------------------------------------------------------------- +// Request / RequestBuilder +// --------------------------------------------------------------------------- + +pub struct Request { + method: Method, + url: Url, + headers: header::HeaderMap, + body: Option, + timeout: Option, +} + +impl Request { + pub fn method(&self) -> &Method { + &self.method + } + + pub fn url(&self) -> &Url { + &self.url + } + + pub fn headers(&self) -> &header::HeaderMap { + &self.headers + } + + pub fn headers_mut(&mut self) -> &mut header::HeaderMap { + &mut self.headers + } + + pub fn body(&self) -> Option<&Body> { + self.body.as_ref() + } +} + +pub struct RequestBuilder { + client: Client, + request: Result, +} + +impl RequestBuilder { + pub fn header(mut self, key: K, value: V) -> Self + where + header::HeaderName: TryFrom, + >::Error: Into, + header::HeaderValue: TryFrom, + >::Error: Into, + { + if let Ok(req) = &mut self.request { + let name = match header::HeaderName::try_from(key) { + Ok(name) => name, + Err(e) => { + self.request = Err(Error::new(Kind::Builder).with_source(e.into())); + return self; + } + }; + let value = match header::HeaderValue::try_from(value) { + Ok(value) => value, + Err(e) => { + self.request = Err(Error::new(Kind::Builder).with_source(e.into())); + return self; + } + }; + req.headers.append(name, value); + } + self + } + + pub fn headers(mut self, headers: header::HeaderMap) -> Self { + if let Ok(req) = &mut self.request { + for (name, value) in headers.iter() { + req.headers.append(name.clone(), value.clone()); + } + } + self + } + + pub fn bearer_auth(self, token: impl fmt::Display) -> Self { + self.header(header::AUTHORIZATION, format!("Bearer {token}")) + } + + pub fn json(mut self, json: &T) -> Self { + if let Ok(req) = &mut self.request { + match serde_json::to_vec(json) { + Ok(bytes) => { + if !req.headers.contains_key(header::CONTENT_TYPE) { + req.headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ); + } + req.body = Some(Body::from(bytes)); + } + Err(e) => { + self.request = Err(Error::new(Kind::Builder).with_source(e)); + } + } + } + self + } + + pub fn query(mut self, query: &T) -> Self { + if let Ok(req) = &mut self.request { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + match query.serialize(serde_urlencoded::Serializer::new(&mut serializer)) { + Ok(_) => { + let encoded = serializer.finish(); + if !encoded.is_empty() { + let new_query = match req.url.query() { + Some(existing) if !existing.is_empty() => { + format!("{existing}&{encoded}") + } + _ => encoded, + }; + req.url.set_query(Some(&new_query)); + } + } + Err(e) => { + self.request = Err(Error::new(Kind::Builder).with_source(e)); + } + } + } + self + } + + pub fn body(mut self, body: impl Into) -> Self { + if let Ok(req) = &mut self.request { + req.body = Some(body.into()); + } + self + } + + pub fn form(mut self, form: &T) -> Self { + if let Ok(req) = &mut self.request { + match serde_urlencoded::to_string(form) { + Ok(encoded) => { + req.headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + req.body = Some(Body::from(encoded)); + } + Err(e) => { + self.request = Err(Error::new(Kind::Builder).with_source(e)); + } + } + } + self + } + + pub fn timeout(mut self, timeout: Duration) -> Self { + if let Ok(req) = &mut self.request { + req.timeout = Some(timeout); + } + self + } + + pub fn build(self) -> Result { + self.request + } + + pub async fn send(self) -> Result { + let client = self.client.clone(); + client.execute(self.request?).await + } +} + +// --------------------------------------------------------------------------- +// Response +// --------------------------------------------------------------------------- + +pub struct Response { + status: StatusCode, + headers: header::HeaderMap, + url: Url, + body: hyper::body::Incoming, + read_timeout: Option, +} + +impl fmt::Debug for Response { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("baml_http::Response") + .field("url", &self.url) + .field("status", &self.status) + .field("headers", &self.headers) + .finish_non_exhaustive() + } +} + +impl Response { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn headers(&self) -> &header::HeaderMap { + &self.headers + } + + pub fn url(&self) -> &Url { + &self.url + } + + pub fn error_for_status(self) -> Result { + if self.status.is_client_error() || self.status.is_server_error() { + Err(Error::new(Kind::Status) + .with_url(self.url.clone()) + .with_status(self.status)) + } else { + Ok(self) + } + } + + pub async fn bytes(self) -> Result { + let url = self.url; + let collect = self.body.collect(); + let collected = match self.read_timeout { + Some(timeout) => tokio::time::timeout(timeout, collect) + .await + .map_err(|_| Error::new(Kind::Timeout).with_url(url.clone()))?, + None => collect.await, + }; + collected + .map(|c| c.to_bytes()) + .map_err(|e| Error::new(Kind::Body).with_url(url).with_source(e)) + } + + pub async fn text(self) -> Result { + let url = self.url.clone(); + let bytes = self.bytes().await?; + String::from_utf8(bytes.to_vec()) + .map_err(|e| Error::new(Kind::Decode).with_url(url).with_source(e)) + } + + pub async fn json(self) -> Result { + let url = self.url.clone(); + let bytes = self.bytes().await?; + serde_json::from_slice(&bytes) + .map_err(|e| Error::new(Kind::Decode).with_url(url).with_source(e)) + } + + /// Stream of body chunks. `read_timeout` (if configured on the client) + /// bounds the wait between consecutive chunks; the timer resets after + /// each received chunk (idle timeout). + pub fn bytes_stream(self) -> BytesStream { + BytesStream { + inner: BodyStream::new(self.body), + url: self.url, + read_timeout: self.read_timeout, + sleep: None, + } + } +} + +/// Body chunk stream returned by [`Response::bytes_stream`]. +/// +/// Hand-rolled (rather than an `async` combinator) so it is `Send + Sync + +/// Unpin`, matching what BAML's stream plumbing requires of reqwest's +/// equivalent. +pub struct BytesStream { + inner: BodyStream, + url: Url, + read_timeout: Option, + sleep: Option>>, +} + +impl futures::Stream for BytesStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + use std::task::Poll; + + use futures::Future; + + let this = self.get_mut(); + loop { + match std::pin::Pin::new(&mut this.inner).poll_next(cx) { + Poll::Ready(Some(Ok(frame))) => { + match frame.into_data() { + Ok(data) => { + // Reset the idle timer on progress. + this.sleep = None; + return Poll::Ready(Some(Ok(data))); + } + // Skip non-data (trailer) frames. + Err(_) => continue, + } + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(Error::new(Kind::Body) + .with_url(this.url.clone()) + .with_source(e)))); + } + Poll::Ready(None) => return Poll::Ready(None), + Poll::Pending => { + if let Some(timeout) = this.read_timeout { + let sleep = this + .sleep + .get_or_insert_with(|| Box::pin(tokio::time::sleep(timeout))); + if sleep.as_mut().poll(cx).is_ready() { + this.sleep = None; + return Poll::Ready(Some(Err( + Error::new(Kind::Timeout).with_url(this.url.clone()) + ))); + } + } + return Poll::Pending; + } + } + } + } +} diff --git a/engine/baml-http/src/lib.rs b/engine/baml-http/src/lib.rs new file mode 100644 index 0000000000..26d0bffdf1 --- /dev/null +++ b/engine/baml-http/src/lib.rs @@ -0,0 +1,35 @@ +//! HTTP client for BAML. +//! +//! BAML code uses `baml_http::...` instead of `reqwest::...`. The crate +//! exposes the subset of the reqwest API that BAML uses: +//! +//! - On native targets it is implemented directly on hyper/hyper-util. This +//! keeps the dependency tree small and makes the engine vendorable in +//! monorepos where reqwest is not importable. +//! - On wasm32 it re-exports reqwest, whose browser (fetch) backend is the +//! only practical option there. +//! +//! ## Native TLS backend (cargo features) +//! - `native-tls` (default): platform TLS (SChannel / Security.framework / +//! system OpenSSL) via hyper-tls. Uses the OS trust store and avoids the +//! `ring` crate; connections use HTTP/1.1 (hyper-tls does not forward the +//! ALPN h2 hint). Best for environments that ban ring or need corporate CAs. +//! - `rustls-tls`: statically-linked rustls + ring with bundled webpki roots, +//! HTTP/2 enabled. Most portable for prebuilt wheels, but pulls in `ring`. +//! - `native-tls-vendored`: native-tls with a statically-vendored OpenSSL +//! (portable Linux wheels without a system libssl). +//! +//! Known differences from reqwest on native targets: +//! - Environment proxies (HTTP_PROXY/HTTPS_PROXY) are not supported. +//! - `read_timeout` applies between body chunks (and to whole buffered body +//! reads) rather than per socket read. + +#[cfg(target_arch = "wasm32")] +mod reqwest_client; +#[cfg(target_arch = "wasm32")] +pub use reqwest_client::*; + +#[cfg(not(target_arch = "wasm32"))] +mod hyper_client; +#[cfg(not(target_arch = "wasm32"))] +pub use hyper_client::*; diff --git a/engine/baml-http/src/reqwest_client.rs b/engine/baml-http/src/reqwest_client.rs new file mode 100644 index 0000000000..0d2bc95ce8 --- /dev/null +++ b/engine/baml-http/src/reqwest_client.rs @@ -0,0 +1,7 @@ +//! reqwest-backed implementation used on wasm32 (browser fetch): pure +//! re-exports, behaviorally identical to using reqwest directly. + +pub use reqwest::{ + get, header, Body, Client, ClientBuilder, Error, IntoUrl, Method, Request, RequestBuilder, + Response, Result, StatusCode, Url, +}; diff --git a/engine/baml-lib/ast/Cargo.toml b/engine/baml-lib/ast/Cargo.toml index 0f88658343..c08997dda7 100644 --- a/engine/baml-lib/ast/Cargo.toml +++ b/engine/baml-lib/ast/Cargo.toml @@ -23,7 +23,6 @@ anyhow.workspace = true pest.workspace = true pest_derive.workspace = true either.workspace = true -test-log.workspace = true pretty = { workspace = true } regex.workspace = true indexmap.workspace = true diff --git a/engine/baml-lib/baml-core/Cargo.toml b/engine/baml-lib/baml-core/Cargo.toml index 28d65b1d96..d17cd5c872 100644 --- a/engine/baml-lib/baml-core/Cargo.toml +++ b/engine/baml-lib/baml-core/Cargo.toml @@ -34,7 +34,7 @@ internal-llm-client = { path = "../llm-client" } internal-baml-jinja-types = { path = "../jinja" } internal-baml-parser-database = { path = "../parser-database" } internal-baml-prompt-parser = { path = "../prompt-parser" } -baml-rpc.workspace = true +baml-rpc = { workspace = true, optional = true } minijinja.workspace = true minijinja-contrib.workspace = true rayon = "1.8.0" @@ -46,11 +46,16 @@ shellwords = "1.1.0" strsim.workspace = true strum.workspace = true textwrap.workspace = true -whoami = "1.4.1" itertools.workspace = true once_cell.workspace = true +[features] +# RPC-typed IR signature hashing, used by the Boundary Studio publisher in +# baml-runtime (enabled via its `studio` feature). Off by default so builds +# without Studio drop the baml-rpc (ts-rs/serde_with) tree. +ir-hasher = ["dep:baml-rpc"] + [dev-dependencies] base64.workspace = true dissimilar.workspace = true diff --git a/engine/baml-lib/baml-core/src/ir/mod.rs b/engine/baml-lib/baml-core/src/ir/mod.rs index b55b1f9e34..5203ddb87a 100644 --- a/engine/baml-lib/baml-core/src/ir/mod.rs +++ b/engine/baml-lib/baml-core/src/ir/mod.rs @@ -1,4 +1,5 @@ pub mod builtin; +#[cfg(feature = "ir-hasher")] pub mod ir_hasher; pub mod ir_helpers; pub mod jinja_helpers; diff --git a/engine/baml-lib/jsonish/Cargo.toml b/engine/baml-lib/jsonish/Cargo.toml index 91b07dddae..08379f275d 100644 --- a/engine/baml-lib/jsonish/Cargo.toml +++ b/engine/baml-lib/jsonish/Cargo.toml @@ -32,7 +32,6 @@ serde_json.workspace = true serde.workspace = true # jsonschema = "0.17.1" either.workspace = true -test-log.workspace = true regex.workspace = true thiserror = "2.0.9" unicode-normalization = "0.1.24" @@ -42,6 +41,7 @@ uuid = { workspace = true, features = ["v4", "js"] } getrandom = { version = "0.2.15", features = ["js"] } [dev-dependencies] +test-log.workspace = true assert-json-diff = "2.0.2" wasm-bindgen-test = "0.3" peak_alloc = "0.2.0" diff --git a/engine/baml-lib/jsonish/src/lib.rs b/engine/baml-lib/jsonish/src/lib.rs index 6cf19c27b0..4021967038 100644 --- a/engine/baml-lib/jsonish/src/lib.rs +++ b/engine/baml-lib/jsonish/src/lib.rs @@ -1,4 +1,7 @@ pub mod helpers; +// Test-only: keep behind cfg(test) so the (large) test corpus and its +// dependencies don't ship in production builds of dependents. +#[cfg(test)] pub mod tests; use anyhow::Result; diff --git a/engine/baml-rpc/Cargo.toml b/engine/baml-rpc/Cargo.toml index 3ecc538f2d..ccfa5930e3 100644 --- a/engine/baml-rpc/Cargo.toml +++ b/engine/baml-rpc/Cargo.toml @@ -46,7 +46,6 @@ web-time.workspace = true static_assertions.workspace = true baml-ids.workspace = true serde_with = "3.12.0" -sha2 = "0.10.9" [features] diff --git a/engine/baml-runtime/Cargo.toml b/engine/baml-runtime/Cargo.toml index aa862d1460..6f8abd8c55 100644 --- a/engine/baml-runtime/Cargo.toml +++ b/engine/baml-runtime/Cargo.toml @@ -29,13 +29,14 @@ arc_with_non_send_sync = "allow" [dependencies] anyhow.workspace = true baml-compiler = { path = "../baml-compiler" } +baml-http = { workspace = true } baml-ids.workspace = true internal-baml-ast = { path = "../baml-lib/ast" } internal-baml-diagnostics = { path = "../baml-lib/diagnostics" } baml-log.workspace = true baml-viz-events = { path = "../baml-viz-events" } baml-types.workspace = true -baml-rpc.workspace = true +baml-rpc = { workspace = true, optional = true } baml-vm = { path = "../baml-vm" } base64.workspace = true bytes.workspace = true @@ -91,10 +92,7 @@ mime = "0.3.17" # For tracing chrono = "0.4.38" tokio-util = { version = "0.7", features = ["rt"] } -async-std = "1.12.0" fastrand = "2.1.0" -test-log = "0.2.16" -include_dir = "0.7.3" infer = "0.16.0" url = "2.5.2" shell-escape = "0.1.5" @@ -107,7 +105,6 @@ aws-smithy-types = { version = "1.2.0", optional = true } aws-smithy-runtime = { version = "1.6.0", optional = true } enum_dispatch = "0.3.13" aws-smithy-json = { version = "0.60.7", optional = true } -pretty_assertions = "1.4.0" valuable = { version = "0.1.0", features = ["derive"] } tracing = { version = "0.1.40", features = ["valuable"] } tracing-log = "0.2.0" @@ -121,9 +118,8 @@ log-once = "0.4.1" once_cell.workspace = true time.workspace = true tempfile = "3.19.0" -sha2 = "0.10.9" -blake3 = "1.8.2" -flate2 = "1.1.5" +blake3 = { version = "1.8.2", optional = true } +flate2 = { version = "1.1.5", optional = true } similar = "2.6" @@ -142,14 +138,6 @@ colored = { version = "2.1.0", default-features = false, features = [ ] } futures-timer = { version = "3.0.3", features = ["wasm-bindgen"] } js-sys.workspace = true -reqwest = { version = "0.12.12", default-features = false, features = [ - "charset", - "http2", - "stream", - "json", - "rustls-tls", -] } -# send_wrapper = { version = "0.6.0", features = ["futures"] } serde-wasm-bindgen = "0.6.5" uuid = { workspace = true, features = ["v4", "serde", "js"] } @@ -172,34 +160,68 @@ aws-config = { version = "=1.5.3", optional = true } # Potential migration https://docs.aws.amazon.com/sdk-for-rust/latest/dg/http.html#tlsProviders aws-sdk-bedrockruntime = { version = "=1.106.0", default-features = false, optional = true, features = [ ] } -axum = "0.7.5" -axum-extra = { version = "0.9.3", features = ["erased-json", "typed-header"] } -criterion = "0.5.1" -# depends on ring -gcp_auth = "0.12.3" +axum = { version = "0.7.5", optional = true } +axum-extra = { version = "0.9.3", features = [ + "erased-json", + "typed-header", +], optional = true } +# Vendored, ring-free fork (native-tls + rsa crate). See vendored/gcp-auth. +gcp_auth = { path = "../vendored/gcp-auth", optional = true } hostname = "0.3.1" -jsonwebtoken = { version = "9.3.0" } -notify-debouncer-full = "0.3.1" -ring = { version = "0.17.4", features = ["std"] } +http-body-util = "0.1" +notify-debouncer-full = { version = "0.3.1", optional = true } tokio = { version = "1", features = ["full"] } -reqwest.workspace = true walkdir = "2.5.0" -which = "6.0.3" +which = "7" indicatif = "0.17" junit-report = "0.8.3" -ratatui = "0.29" -unicode-width = "0.1" -dirs = "5.0" -supports-color = "3" -crossterm = "0.28" -reedline = "0.37" +ratatui = { version = "0.29", optional = true } +unicode-width = { version = "0.1", optional = true } +dirs = { version = "5.0", optional = true } +supports-color = { version = "3", optional = true } +crossterm = { version = "0.28", optional = true } [features] -default = ["bedrock"] +default = [ + "bedrock", + "serve", + "dev", + "repl", + "vertex", + "optimize", + "tui", + "studio", +] defaults = ["skip-integ-tests"] +# Statically vendor OpenSSL for the native-tls backend (portable Linux builds +# with no system libssl). Forwarded from baml-http. +native-tls-vendored = ["baml-http/native-tls-vendored"] internal = [] skip-integ-tests = [] +# `baml serve`: the local OpenAPI HTTP server. Off in minimal/vendored builds to +# drop the axum/tower web-server tree. +serve = ["dep:axum", "dep:axum-extra"] +# `baml dev`: serve + file-watching (notify). +dev = ["serve", "dep:notify-debouncer-full"] +# Interactive terminal UIs (ratatui + crossterm). Without it, `baml init` +# falls back to its plain-text output. +tui = ["dep:ratatui", "dep:crossterm"] +# `baml optimize` (GEPA prompt optimizer; TUI-driven). +optimize = ["tui"] +# Boundary Studio tracing publisher (trace/blob upload, RPC event conversion). +# Off in vendored builds: drops baml-rpc (ts-rs, serde_with) and blake3. +studio = [ + "dep:baml-rpc", + "dep:blake3", + "dep:flate2", + "internal-baml-core/ir-hasher", +] +# `baml repl`: interactive REPL. +repl = ["dep:dirs", "dep:supports-color", "dep:unicode-width", "tui"] +# Vertex AI (Google Cloud) provider auth. Off to drop the gcp_auth tree if the +# vertex-ai provider is not used. +vertex = ["dep:gcp_auth"] # Use the THIR interpreter runtime instead of the VM bytecode runtime thir-interpreter = [] # AWS Bedrock provider support. On by default; disable (e.g. --no-default-features) @@ -222,6 +244,10 @@ bedrock = [ assert_cmd = "2" console_log = "1" criterion = "0.5.1" +# Tests use reqwest directly; production code goes through baml-http. +reqwest.workspace = true +pretty_assertions = "1.4.0" +test-log = "0.2.16" dissimilar = "1.0.4" expect-test = "1.1.0" indoc.workspace = true diff --git a/engine/baml-runtime/src/async_vm_runtime.rs b/engine/baml-runtime/src/async_vm_runtime.rs index 4dd3724a8c..352b645311 100644 --- a/engine/baml-runtime/src/async_vm_runtime.rs +++ b/engine/baml-runtime/src/async_vm_runtime.rs @@ -593,7 +593,7 @@ impl BamlAsyncVmRuntime { async move { let response = 'res: { - let client = reqwest::Client::new(); + let client = baml_http::Client::new(); let req = match &url_or_request { BamlValue::String(url) => { @@ -629,10 +629,10 @@ impl BamlAsyncVmRuntime { }; if let Some(BamlValue::Map(headers)) = fields.get("headers") { - let mut header_map = reqwest::header::HeaderMap::new(); + let mut header_map = baml_http::header::HeaderMap::new(); for (k, v) in headers { - let Ok(key) = reqwest::header::HeaderName::from_str(k) else { + let Ok(key) = baml_http::header::HeaderName::from_str(k) else { break 'res Err(anyhow!( "baml.fetch_as: expected header key to be a valid HTTP header name, got {}", k @@ -646,7 +646,7 @@ impl BamlAsyncVmRuntime { )); }; - let Ok(value) = reqwest::header::HeaderValue::from_str(value_as_string) else { + let Ok(value) = baml_http::header::HeaderValue::from_str(value_as_string) else { break 'res Err(anyhow!( "baml.fetch_as: expected header value to be a string, got {}", v diff --git a/engine/baml-runtime/src/cli/init_ui.rs b/engine/baml-runtime/src/cli/init_ui.rs index 2aa9ceb48b..b3289ddda6 100644 --- a/engine/baml-runtime/src/cli/init_ui.rs +++ b/engine/baml-runtime/src/cli/init_ui.rs @@ -4,11 +4,13 @@ use std::{ }; use anyhow::Result; +#[cfg(feature = "tui")] use crossterm::{ event::{self, Event, KeyCode}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +#[cfg(feature = "tui")] use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -33,6 +35,7 @@ pub enum StepStatus { Failed, } +#[cfg(feature = "tui")] pub struct InitUI { steps: Vec, terminal: Terminal>, @@ -41,10 +44,46 @@ pub struct InitUI { last_update: Instant, } +/// Stub used when built without the `tui` feature: `new()` always fails, so +/// `InitUIContext` takes its plain-text (non-interactive) path everywhere. +#[cfg(not(feature = "tui"))] +pub struct InitUI { + steps: Vec, +} + +#[cfg(not(feature = "tui"))] +impl InitUI { + pub fn new() -> Result { + anyhow::bail!("interactive UI unavailable: BAML was built without the `tui` feature") + } + + pub fn add_step(&mut self, message: String) { + self.steps.push(InitStep { + message, + status: StepStatus::Pending, + completion_time: None, + }); + } + + pub fn update_step(&mut self, _index: usize, _status: StepStatus) {} + + pub fn render(&mut self) -> Result<()> { + Ok(()) + } + + pub fn cleanup(self) -> Result<()> { + Ok(()) + } +} + +#[cfg(feature = "tui")] const PURPLE_COLOR: Color = Color::Rgb(142, 36, 170); +#[cfg(feature = "tui")] const ANIMATION_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +#[cfg(feature = "tui")] const DOT_ANIMATION: &[&str] = &["⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"]; +#[cfg(feature = "tui")] impl InitUI { pub fn new() -> Result { enable_raw_mode()?; @@ -339,23 +378,28 @@ impl InitUIContext { #[allow(clippy::print_stderr)] pub fn show_error(message: &str) -> Result<()> { // Check if we're in a TTY before attempting to create a fancy error UI - if !io::stdout().is_terminal() { + // (and that the fancy UI was compiled in at all). + if !cfg!(feature = "tui") || !io::stdout().is_terminal() { // Non-interactive mode: just print the error to stderr eprintln!("Error: {}", message); return Ok(()); } // Try to create the fancy error UI, but fallback gracefully if it fails + #[cfg(feature = "tui")] match show_error_ui(message) { - Ok(()) => Ok(()), + Ok(()) => return Ok(()), Err(_) => { // Failed to create UI, fallback to simple error message eprintln!("Error: {}", message); - Ok(()) + return Ok(()); } } + #[allow(unreachable_code)] + Ok(()) } +#[cfg(feature = "tui")] fn show_error_ui(message: &str) -> Result<()> { enable_raw_mode()?; diff --git a/engine/baml-runtime/src/cli/mod.rs b/engine/baml-runtime/src/cli/mod.rs index c77fb44eba..830f33fa33 100644 --- a/engine/baml-runtime/src/cli/mod.rs +++ b/engine/baml-runtime/src/cli/mod.rs @@ -1,12 +1,16 @@ pub mod check; +#[cfg(feature = "dev")] pub mod dev; pub(crate) mod dotenv; pub mod dump_intermediate; pub mod generate; pub mod init; pub mod init_ui; +#[cfg(feature = "optimize")] pub mod optimize; +#[cfg(feature = "repl")] pub mod repl; +#[cfg(feature = "serve")] pub mod serve; pub mod testing; diff --git a/engine/baml-runtime/src/internal/llm_client/llm_provider.rs b/engine/baml-runtime/src/internal/llm_client/llm_provider.rs index 815a852075..3f793f55b0 100644 --- a/engine/baml-runtime/src/internal/llm_client/llm_provider.rs +++ b/engine/baml-runtime/src/internal/llm_client/llm_provider.rs @@ -124,7 +124,7 @@ impl LLMProvider { stream: bool, ctx: &RuntimeContext, client_lookup: &'a impl InternalClientLookup<'a>, - ) -> Result { + ) -> Result { match self { LLMProvider::Primitive(provider) => { provider.build_request(prompt, allow_proxy, stream).await diff --git a/engine/baml-runtime/src/internal/llm_client/mod.rs b/engine/baml-runtime/src/internal/llm_client/mod.rs index 6c9fc0437b..5ed699fcfd 100644 --- a/engine/baml-runtime/src/internal/llm_client/mod.rs +++ b/engine/baml-runtime/src/internal/llm_client/mod.rs @@ -12,6 +12,7 @@ pub mod traits; use std::error::Error; use anyhow::{Context, Result}; +use baml_http::StatusCode; use baml_types::{BamlMap, BamlValueWithMeta, JinjaExpression, ResponseCheck, TypeIR}; use internal_baml_core::ir::{repr::IntermediateRepr, ClientWalker, IRHelper, IRHelperExtended}; use internal_baml_jinja::RenderedPrompt; @@ -24,7 +25,6 @@ use jsonish::{ }, BamlValueWithFlags, }; -use reqwest::StatusCode; use serde::{Deserialize, Serialize}; #[cfg(target_arch = "wasm32")] use wasm_bindgen::JsValue; diff --git a/engine/baml-runtime/src/internal/llm_client/orchestrator/call.rs b/engine/baml-runtime/src/internal/llm_client/orchestrator/call.rs index 7170ca7afb..6d615f825b 100644 --- a/engine/baml-runtime/src/internal/llm_client/orchestrator/call.rs +++ b/engine/baml-runtime/src/internal/llm_client/orchestrator/call.rs @@ -3,7 +3,11 @@ use baml_ids::HttpRequestId; use baml_types::BamlValue; use internal_baml_core::ir::repr::IntermediateRepr; use jsonish::{BamlValueWithFlags, ResponseBamlValue}; +#[cfg(not(target_family = "wasm"))] +use tokio::time::sleep; use tokio_util::sync::CancellationToken; +#[cfg(target_family = "wasm")] +use wasmtimer::tokio::sleep; use web_time::Duration; use super::{OrchestrationScope, OrchestratorNodeIterator}; @@ -170,7 +174,7 @@ pub async fn orchestrate( // Sleep if needed if let Some(duration) = sleep_duration { total_sleep_duration += duration; - async_std::task::sleep(duration).await; + sleep(duration).await; } Some(result) diff --git a/engine/baml-runtime/src/internal/llm_client/orchestrator/stream.rs b/engine/baml-runtime/src/internal/llm_client/orchestrator/stream.rs index 6f90bc42ac..7a71d70492 100644 --- a/engine/baml-runtime/src/internal/llm_client/orchestrator/stream.rs +++ b/engine/baml-runtime/src/internal/llm_client/orchestrator/stream.rs @@ -1,7 +1,6 @@ use std::sync::Arc; use anyhow::Result; -use async_std::stream::StreamExt; use baml_ids::HttpRequestId; use baml_types::BamlValue; use futures::StreamExt as FuturesStreamExt; @@ -526,7 +525,9 @@ where // Sleep if needed if let Some(duration) = sleep_duration { total_sleep_duration += duration; - async_std::task::sleep(duration).await; + // tokio::time (native) / wasmtimer::tokio (wasm), per the + // cfg'd glob imports above. + sleep(duration).await; } Some(result) diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/anthropic/anthropic_client.rs b/engine/baml-runtime/src/internal/llm_client/primitive/anthropic/anthropic_client.rs index 38715a9773..167e3d1fcc 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/anthropic/anthropic_client.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/anthropic/anthropic_client.rs @@ -44,7 +44,7 @@ pub struct AnthropicClient { properties: ResolvedAnthropic, // clients - client: reqwest::Client, + client: baml_http::Client, } // resolves/constructs PostRequestProperties from the client's options and runtime context, fleshing out the needed headers and parameters @@ -252,7 +252,7 @@ impl AnthropicClient { // how to build the HTTP request for requests impl RequestBuilder for AnthropicClient { - fn http_client(&self) -> &reqwest::Client { + fn http_client(&self) -> &baml_http::Client { &self.client } @@ -262,7 +262,7 @@ impl RequestBuilder for AnthropicClient { allow_proxy: bool, stream: bool, expose_secrets: bool, - ) -> Result { + ) -> Result { let destination_url = if allow_proxy { self.properties .proxy_url diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/aws/custom_http_client.rs b/engine/baml-runtime/src/internal/llm_client/primitive/aws/custom_http_client.rs index 19163c6aa4..a464c7cf15 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/aws/custom_http_client.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/aws/custom_http_client.rs @@ -19,7 +19,7 @@ use {futures::channel::oneshot, wasm_bindgen_futures::spawn_local}; use crate::request::create_client; -/// Returns a wrapper around the global reqwest client. +/// Returns a wrapper around the global baml-http client. /// [HttpClient]. #[cfg(not(target_arch = "wasm32"))] // Keep function non-WASM for now pub fn client() -> anyhow::Result { @@ -35,16 +35,16 @@ pub fn client() -> anyhow::Result { Ok(Client::new(client.clone())) } -/// A wrapper around [reqwest::Client] that implements [HttpClient]. +/// A wrapper around [baml_http::Client] that implements [HttpClient]. /// /// This is required to support using proxy servers with the AWS SDK. #[derive(Debug, Clone)] pub struct Client { - inner: reqwest::Client, + inner: baml_http::Client, } impl Client { - pub fn new(client: reqwest::Client) -> Self { + pub fn new(client: baml_http::Client) -> Self { Self { inner: client } } } @@ -133,8 +133,8 @@ impl From for ConnectorError { } } -impl From for CallError { - fn from(err: reqwest::Error) -> Self { +impl From for CallError { + fn from(err: baml_http::Error) -> Self { if err.is_timeout() { return CallError::timeout(err); } @@ -161,13 +161,13 @@ enum CallErrorKind { } #[derive(Debug)] -struct ReqwestConnector { - client: reqwest::Client, +struct HttpClientConnector { + client: baml_http::Client, timeout: Option, } // See https://github.com/aws/amazon-q-developer-cli/pull/1199 -impl HttpConnector for ReqwestConnector { +impl HttpConnector for HttpClientConnector { fn call(&self, request: Request) -> HttpConnectorFuture { let client = self.client.clone(); let timeout = self.timeout; @@ -176,7 +176,7 @@ impl HttpConnector for ReqwestConnector { let future = async move { // Non-WASM logic (direct send) let mut req_builder = client.request( - reqwest::Method::from_bytes(request.method().as_bytes()).map_err(|err| { + baml_http::Method::from_bytes(request.method().as_bytes()).map_err(|err| { CallError::user_with_source("failed to create method name", err) })?, request.uri().to_owned(), @@ -196,11 +196,28 @@ impl HttpConnector for ReqwestConnector { req_builder = req_builder.timeout(timeout); } - let reqwest_response = req_builder.send().await.map_err(CallError::from)?; + let response = req_builder.send().await.map_err(CallError::from)?; let http_response = { - let (parts, body) = http::Response::from(reqwest_response).into_parts(); - http::Response::from_parts(parts, SdkBody::from_body_1_x(body)) + use futures::StreamExt; + let status = response.status(); + let headers = response.headers().clone(); + // Preserve streaming (needed for e.g. ConverseStream) by + // wrapping the chunk stream as an http_body 1.x Body. + let frames = response.bytes_stream().map(|chunk| { + chunk + .map(http_body::Frame::data) + .map_err(|e| Box::new(e) as Box) + }); + let body = SdkBody::from_body_1_x(http_body_util::StreamBody::new(frames)); + + let mut builder = http::Response::builder().status(status); + for (name, value) in headers.iter() { + builder = builder.header(name, value); + } + builder + .body(body) + .map_err(|e| CallError::other("failed to build http::Response", e))? }; Ok( @@ -219,7 +236,7 @@ impl HttpConnector for ReqwestConnector { // Use a closure to handle errors let result = (async { let mut req_builder = client.request( - reqwest::Method::from_bytes(request.method().as_bytes()).map_err( + baml_http::Method::from_bytes(request.method().as_bytes()).map_err( |err| CallError::user_with_source("failed to create method name", err), )?, request.uri().to_owned(), @@ -235,13 +252,13 @@ impl HttpConnector for ReqwestConnector { .to_owned(); req_builder = req_builder.body(body_bytes); - let reqwest_response = req_builder.send().await.map_err(CallError::from)?; + let response = req_builder.send().await.map_err(CallError::from)?; // Use manual construction for WASM response conversion let http_response = { - let status = reqwest_response.status(); - let headers = reqwest_response.headers().clone(); - let body_bytes = reqwest_response + let status = response.status(); + let headers = response.headers().clone(); + let body_bytes = response .bytes() .await .map_err(|e| CallError::other("failed to read response body", e))?; @@ -292,16 +309,10 @@ impl HttpClient for Client { } else { settings.read_timeout() }; - let connector = ReqwestConnector { + let connector = HttpClientConnector { client: self.inner.clone(), timeout, }; SharedHttpConnector::new(connector) } } - -// --- Non-WASM Implementation using Reqwest --- -#[cfg(not(target_arch = "wasm32"))] -mod reqwest_impl { - use std::time::Duration; -} diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/aws/wasm.rs b/engine/baml-runtime/src/internal/llm_client/primitive/aws/wasm.rs index 5cb327b511..f8dd68a56f 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/aws/wasm.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/aws/wasm.rs @@ -79,9 +79,9 @@ pin_project! { unsafe impl Send for StreamWrapper {} unsafe impl Sync for StreamWrapper {} -impl>> http_body::Body for StreamWrapper { +impl>> http_body::Body for StreamWrapper { type Data = bytes::Bytes; - type Error = reqwest::Error; + type Error = baml_http::Error; fn poll_frame( self: Pin<&mut Self>, @@ -102,18 +102,18 @@ impl>> http_body::Body for Stream #[derive(Debug, Clone)] struct BrowserHttp2 { - client: Arc, + client: Arc, } impl BrowserHttp2 { pub fn new() -> Self { Self { - client: Arc::new(reqwest::Client::new()), + client: Arc::new(baml_http::Client::new()), } } async fn send3(&self, smithy_req: Request) -> Result, ConnectorError> { - let method = match reqwest::Method::from_bytes(smithy_req.method().as_bytes()) { + let method = match baml_http::Method::from_bytes(smithy_req.method().as_bytes()) { Ok(method) => method, Err(e) => return Err(ConnectorError::user(Box::new(e))), }; diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/google/googleai_client.rs b/engine/baml-runtime/src/internal/llm_client/primitive/google/googleai_client.rs index 18f5836809..38ce6d888c 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/google/googleai_client.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/google/googleai_client.rs @@ -34,7 +34,7 @@ use crate::{ pub struct GoogleAIClient { pub name: String, - pub client: reqwest::Client, + pub client: baml_http::Client, pub retry_policy: Option, pub context: RenderContext_Client, pub features: ModelFeatures, @@ -209,7 +209,7 @@ impl GoogleAIClient { } impl RequestBuilder for GoogleAIClient { - fn http_client(&self) -> &reqwest::Client { + fn http_client(&self) -> &baml_http::Client { &self.client } @@ -219,7 +219,7 @@ impl RequestBuilder for GoogleAIClient { allow_proxy: bool, stream: bool, expose_secrets: bool, - ) -> Result { + ) -> Result { let mut should_stream = "generateContent"; if stream { should_stream = "streamGenerateContent?alt=sse"; diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/mod.rs b/engine/baml-runtime/src/internal/llm_client/primitive/mod.rs index 989471c14e..25d4823d14 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/mod.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/mod.rs @@ -267,7 +267,7 @@ impl LLMPrimitiveProvider { prompt: either::Either<&String, &[RenderedChatMessage]>, allow_proxy: bool, stream: bool, - ) -> Result { + ) -> Result { match self { LLMPrimitiveProvider::OpenAI(client) => { client @@ -452,7 +452,7 @@ mod tests { allow_proxy: bool, stream: bool, expose_secrets: bool, - ) -> Result { + ) -> Result { unimplemented!("Not used in tests") } @@ -460,7 +460,7 @@ mod tests { &self.request_options } - fn http_client(&self) -> &reqwest::Client { + fn http_client(&self) -> &baml_http::Client { unimplemented!("Not used in tests") } diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/openai/openai_client.rs b/engine/baml-runtime/src/internal/llm_client/primitive/openai/openai_client.rs index ce2bf8b6aa..07ce635c75 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/openai/openai_client.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/openai/openai_client.rs @@ -38,7 +38,7 @@ pub struct OpenAIClient { features: ModelFeatures, properties: ResolvedOpenAI, // clients - client: reqwest::Client, + client: baml_http::Client, } impl WithRetryPolicy for OpenAIClient { @@ -403,7 +403,7 @@ impl OpenAIClient { } impl RequestBuilder for OpenAIClient { - fn http_client(&self) -> &reqwest::Client { + fn http_client(&self) -> &baml_http::Client { &self.client } @@ -413,7 +413,7 @@ impl RequestBuilder for OpenAIClient { allow_proxy: bool, stream: bool, expose_secrets: bool, - ) -> Result { + ) -> Result { let destination_url = if allow_proxy { self.properties .proxy_url @@ -929,7 +929,7 @@ mod tests { media_url_handler: internal_llm_client::MediaUrlHandler::default(), http_config: Default::default(), }, - client: reqwest::Client::new(), + client: baml_http::Client::new(), }; let strategy = responses_client.get_provider_strategy(); @@ -983,7 +983,7 @@ mod tests { media_url_handler: internal_llm_client::MediaUrlHandler::default(), http_config: Default::default(), }, - client: reqwest::Client::new(), + client: baml_http::Client::new(), }; let strategy = openai_client.get_provider_strategy(); @@ -1079,7 +1079,7 @@ mod tests { media_url_handler: internal_llm_client::MediaUrlHandler::default(), http_config: Default::default(), }, - client: reqwest::Client::new(), + client: baml_http::Client::new(), }; let body_value = strategy diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/request.rs b/engine/baml-runtime/src/internal/llm_client/primitive/request.rs index 42ac956967..01f2e67c76 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/request.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/request.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::{Context, Result}; +use baml_http::{header::HeaderMap, Response, StatusCode}; use baml_types::{ tracing::events::{ClientDetails, HTTPBody, HTTPRequest, HTTPResponse, TraceEvent}, BamlMap, @@ -9,7 +10,6 @@ use bytes::Bytes; use http::Response as HttpResponse; use internal_baml_jinja::{RenderContext_Client, RenderedChatMessage, RenderedPrompt}; pub use internal_llm_client::ResponseType; -use reqwest::{header::HeaderMap, Response, StatusCode}; use serde::de::DeserializeOwned; use serde_json::json; @@ -30,7 +30,7 @@ pub struct LoggedHttpResponse { } impl LoggedHttpResponse { - pub async fn new_from_reqwest(resp: reqwest::Response) -> Result { + pub async fn new_from_reqwest(resp: baml_http::Response) -> Result { let status = resp.status(); let url = resp.url().to_string(); let headers = resp.headers().clone(); @@ -63,10 +63,10 @@ pub trait RequestBuilder { allow_proxy: bool, stream: bool, expose_secrets: bool, - ) -> Result; + ) -> Result; fn request_options(&self) -> &BamlMap; - fn http_client(&self) -> &reqwest::Client; + fn http_client(&self) -> &baml_http::Client; fn http_config(&self) -> &internal_llm_client::HttpConfig; } @@ -80,7 +80,7 @@ pub(crate) fn to_prompt( } pub enum JsonBodyInput<'a> { - ReqwestBody(Option<&'a reqwest::Body>), + ReqwestBody(Option<&'a baml_http::Body>), Bytes(&'a [u8]), String(String), } @@ -150,7 +150,7 @@ pub(crate) async fn build_and_log_outbound_request( allow_proxy: bool, stream: bool, runtime_context: &impl HttpContext, -) -> Result<(web_time::SystemTime, web_time::Instant, reqwest::Request), LLMResponse> { +) -> Result<(web_time::SystemTime, web_time::Instant, baml_http::Request), LLMResponse> { let system_now = web_time::SystemTime::now(); let instant_now = web_time::Instant::now(); @@ -200,7 +200,7 @@ pub(crate) async fn build_and_log_outbound_request( HTTPBody::new( built_req .body() - .and_then(reqwest::Body::as_bytes) + .and_then(baml_http::Body::as_bytes) .unwrap_or_default() .into(), ), @@ -219,7 +219,7 @@ pub(crate) async fn build_and_log_outbound_request( pub async fn execute_request( client: &(impl WithClient + RequestBuilder), - built_req: reqwest::Request, + built_req: baml_http::Request, prompt: either::Either<&String, &[RenderedChatMessage]>, system_now: web_time::SystemTime, instant_now: web_time::Instant, @@ -255,7 +255,7 @@ pub async fn execute_request( log_http_response( runtime_context, e.status() - .unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR) + .unwrap_or(baml_http::StatusCode::INTERNAL_SERVER_ERROR) .as_u16(), None, HTTPBody::new(format!("No response. Error: {message}").into_bytes()), diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/stream_request.rs b/engine/baml-runtime/src/internal/llm_client/primitive/stream_request.rs index cea1defbd0..7abb64cc4f 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/stream_request.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/stream_request.rs @@ -1,13 +1,13 @@ use std::{collections::HashMap, ops::Deref}; use anyhow::{Context, Result}; +use baml_http::Response; use baml_types::{ tracing::events::{HTTPRequest, HTTPResponse, HTTPResponseStream, SSEEvent, TraceEvent}, BamlMap, }; use futures::{StreamExt, TryStreamExt}; use internal_baml_jinja::RenderedChatMessage; -use reqwest::Response; use serde::de::DeserializeOwned; use super::{ diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/mod.rs b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/mod.rs index 2079fb6a78..69908f1586 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/mod.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/mod.rs @@ -4,11 +4,18 @@ pub(super) mod wasm_auth; #[cfg(target_arch = "wasm32")] pub(super) use wasm_auth as auth; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "vertex"))] pub(super) mod std_auth; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "vertex"))] pub(super) use std_auth as auth; +// Without the `vertex` feature the gcp_auth dependency tree is dropped; this +// stub keeps VertexClient compiling and fails at request time instead. +#[cfg(all(not(target_arch = "wasm32"), not(feature = "vertex")))] +pub(super) mod stub_auth; +#[cfg(all(not(target_arch = "wasm32"), not(feature = "vertex")))] +pub(super) use stub_auth as auth; + mod types; mod vertex_client; pub use vertex_client::VertexClient; diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/stub_auth.rs b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/stub_auth.rs new file mode 100644 index 0000000000..c45c993a18 --- /dev/null +++ b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/stub_auth.rs @@ -0,0 +1,39 @@ +//! Stub auth backend used when baml-runtime is built without the `vertex` +//! feature (e.g. vendored/minimal builds that want to drop the gcp_auth +//! dependency tree). Mirrors the API surface of `std_auth::VertexAuth` that +//! `VertexClient` uses; construction always fails with a clear error. + +use std::sync::Arc; + +use anyhow::Result; +use internal_llm_client::vertex::ResolvedGcpAuthStrategy; + +pub enum VertexAuth {} + +/// Mirrors `gcp_auth::Token` far enough for `VertexClient`. +pub struct Token; + +impl Token { + pub fn as_str(&self) -> &str { + "" + } +} + +impl VertexAuth { + pub async fn get_or_create( + _auth_strategy: &ResolvedGcpAuthStrategy, + ) -> Result> { + anyhow::bail!( + "The vertex-ai provider is unavailable: BAML was compiled without the `vertex` \ + feature. Rebuild baml-runtime with the `vertex` feature enabled to use Vertex AI." + ) + } + + pub async fn token(&self, _scopes: &[&str]) -> Result> { + match *self {} + } + + pub async fn project_id(&self) -> Result> { + match *self {} + } +} diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/vertex_client.rs b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/vertex_client.rs index 1ede789396..be9884f6db 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/vertex_client.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/vertex_client.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use baml_types::BamlMediaContent; use chrono::Utc; use futures::StreamExt; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "vertex"))] use gcp_auth::TokenProvider; use internal_baml_core::ir::ClientWalker; use internal_baml_jinja::{RenderContext_Client, RenderedChatMessage}; @@ -40,7 +40,7 @@ use crate::{ pub struct VertexClient { pub name: String, - pub client: reqwest::Client, + pub client: baml_http::Client, pub retry_policy: Option, pub context: RenderContext_Client, pub features: ModelFeatures, @@ -233,7 +233,7 @@ impl VertexClient { } impl RequestBuilder for VertexClient { - fn http_client(&self) -> &reqwest::Client { + fn http_client(&self) -> &baml_http::Client { &self.client } @@ -245,7 +245,7 @@ impl RequestBuilder for VertexClient { // There are no leakable secrets in the Vertex request because // VertexAuth can not be built in the WASM environment. _expose_secrets: bool, - ) -> Result { + ) -> Result { // Determine if API key auth is being used (query param 'key') let has_api_key_query = self.properties.query_params.contains_key("key"); let mut vertex_auth: Option> = None; diff --git a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs index 6036079b03..5eb96492d0 100644 --- a/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs +++ b/engine/baml-runtime/src/internal/llm_client/primitive/vertex/wasm_auth.rs @@ -149,7 +149,7 @@ impl ServiceAccount { .map_err(|e| anyhow::anyhow!(format!("{e:?}")))?; // Make the token request - let client = reqwest::Client::new(); + let client = baml_http::Client::new(); let params = [ ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), ("assertion", &jwt), diff --git a/engine/baml-runtime/src/internal/llm_client/traits/mod.rs b/engine/baml-runtime/src/internal/llm_client/traits/mod.rs index c2f2b223ad..8aef49269e 100644 --- a/engine/baml-runtime/src/internal/llm_client/traits/mod.rs +++ b/engine/baml-runtime/src/internal/llm_client/traits/mod.rs @@ -195,7 +195,7 @@ fn escape_single_quotes(s: &str) -> String { fn to_curl_command( url: &str, method: &str, - headers: &reqwest::header::HeaderMap, + headers: &baml_http::header::HeaderMap, body: Vec, env_vars: &std::collections::HashMap, expose_secrets: bool, @@ -362,7 +362,7 @@ where pub trait SseResponseTrait { fn response_stream( &self, - resp: reqwest::Response, + resp: baml_http::Response, prompt: &[internal_baml_jinja::RenderedChatMessage], system_start: web_time::SystemTime, instant_start: web_time::Instant, @@ -772,8 +772,8 @@ fn as_base64(maybe_base64_url: &str) -> Option<(&str, &str)> { async fn fetch_with_proxy( url: &str, proxy_url: Option<&str>, -) -> Result { - let client = reqwest::Client::new(); +) -> Result { + let client = baml_http::Client::new(); let request = if let Some(proxy) = proxy_url { let new_proxy_url = format!( diff --git a/engine/baml-runtime/src/lib.rs b/engine/baml-runtime/src/lib.rs index a354a7fa84..e9bd0fda75 100644 --- a/engine/baml-runtime/src/lib.rs +++ b/engine/baml-runtime/src/lib.rs @@ -9,7 +9,7 @@ pub(crate) mod internal; pub mod cli; pub mod client_registry; pub mod errors; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "optimize"))] pub mod optimize; pub mod request; pub mod runtime; @@ -104,10 +104,9 @@ use runtime_interface::{ExperimentalTracingInterface, RuntimeConstructor}; pub(crate) use runtime_methods::prepare_function::PreparedFunctionArgs; use serde_json::{self, json}; use tracing::{BamlTracer, TracingCall}; -use tracingv2::{ - publisher::flush, - storage::storage::{Collector, BAML_TRACER}, -}; +#[cfg(feature = "studio")] +use tracingv2::publisher::flush; +use tracingv2::storage::storage::{Collector, BAML_TRACER}; use type_builder::TypeBuilder; pub use types::*; use web_time::{Duration, SystemTime}; @@ -434,6 +433,7 @@ pub struct BamlRuntime { pub async_runtime: Arc, /// Shared env vars for the tracingv2 publisher, updated at each call site. + #[cfg(feature = "studio")] publisher_env_vars: Option, } @@ -503,23 +503,29 @@ impl BamlRuntime { tracer_wrapper: Arc::new(BamlTracerWrapper::new(env_vars)?), #[cfg(not(target_arch = "wasm32"))] async_runtime: rt.clone(), + #[cfg(feature = "studio")] publisher_env_vars: None, }; - let ast_sig: Arc = Arc::new( - Arc::new(runtime_for_ast) - .try_into() - .context("Internal error: Failed to create event publisher for BAML runtime")?, - ); + #[cfg(feature = "studio")] + let publisher_env_vars = { + let ast_sig: Arc = + Arc::new(Arc::new(runtime_for_ast).try_into().context( + "Internal error: Failed to create event publisher for BAML runtime", + )?); - let publisher_env_vars = tracingv2::publisher::PublisherEnvVars::new(env_vars.clone()); + let publisher_env_vars = tracingv2::publisher::PublisherEnvVars::new(env_vars.clone()); - tracingv2::publisher::register_publisher( - ast_sig, - publisher_env_vars.clone(), - #[cfg(not(target_arch = "wasm32"))] - rt.clone(), - ); + tracingv2::publisher::register_publisher( + ast_sig, + publisher_env_vars.clone(), + #[cfg(not(target_arch = "wasm32"))] + rt.clone(), + ); + publisher_env_vars + }; + #[cfg(not(feature = "studio"))] + let _ = runtime_for_ast; let runtime = BamlRuntime { ir: ir.clone(), @@ -531,6 +537,7 @@ impl BamlRuntime { tracer_wrapper: Arc::new(BamlTracerWrapper::new(env_vars)?), #[cfg(not(target_arch = "wasm32"))] async_runtime: rt.clone(), + #[cfg(feature = "studio")] publisher_env_vars: Some(publisher_env_vars), }; @@ -968,6 +975,7 @@ impl BamlRuntime { G: Fn(), { baml_log::set_from_env(&env_vars).unwrap(); + #[cfg(feature = "studio")] if let Some(ref publisher_env_vars) = self.publisher_env_vars { publisher_env_vars.update(env_vars.clone()); } @@ -1397,6 +1405,7 @@ impl BamlRuntime { ) -> (Result, FunctionCallId) { // baml_log::info!("env vars: {:#?}", env_vars.clone()); baml_log::set_from_env(&env_vars).unwrap(); + #[cfg(feature = "studio")] if let Some(ref publisher_env_vars) = self.publisher_env_vars { publisher_env_vars.update(env_vars.clone()); } @@ -1470,6 +1479,7 @@ impl BamlRuntime { expr_tx: Option>>, ) -> Result { baml_log::set_from_env(&env_vars).unwrap(); + #[cfg(feature = "studio")] if let Some(ref publisher_env_vars) = self.publisher_env_vars { publisher_env_vars.update(env_vars.clone()); } @@ -1577,7 +1587,7 @@ impl BamlRuntime { HTTPBody::new( request .body() - .and_then(reqwest::Body::as_bytes) + .and_then(baml_http::Body::as_bytes) .unwrap_or_default() .into(), ), @@ -2087,14 +2097,14 @@ impl ExperimentalTracingInterface for BamlRuntime { } fn flush(&self) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "studio"))] { if let Err(e) = self.async_runtime.block_on(flush()) { log::error!("Failed to flush: {e}"); baml_log::debug!("Failed to flush: {}", e); } } - #[cfg(target_arch = "wasm32")] + #[cfg(all(target_arch = "wasm32", feature = "studio"))] { wasm_bindgen_futures::spawn_local(async move { if let Err(e) = flush().await { diff --git a/engine/baml-runtime/src/request/mod.rs b/engine/baml-runtime/src/request/mod.rs index 8253c82ee0..bdab41f021 100644 --- a/engine/baml-runtime/src/request/mod.rs +++ b/engine/baml-runtime/src/request/mod.rs @@ -1,13 +1,13 @@ use anyhow::{Context, Result}; use web_time::Duration; -fn builder() -> reqwest::ClientBuilder { +fn builder() -> baml_http::ClientBuilder { cfg_if::cfg_if! { if #[cfg(target_arch = "wasm32")] { - reqwest::Client::builder() + baml_http::Client::builder() } else { let danger_accept_invalid_certs = matches!(std::env::var("DANGER_ACCEPT_INVALID_CERTS").as_deref(), Ok("1")); - reqwest::Client::builder() + baml_http::Client::builder() // NB: we can NOT set a total request timeout here: our users // regularly have requests that take multiple minutes, due to how // long LLMs take @@ -26,22 +26,22 @@ fn builder() -> reqwest::ClientBuilder { } } -pub fn create_client() -> Result { +pub fn create_client() -> Result { builder().build().context("Failed to create reqwest client") } pub fn create_http_client( http_config: &internal_llm_client::HttpConfig, -) -> Result { +) -> Result { cfg_if::cfg_if! { if #[cfg(target_arch = "wasm32")] { // WASM doesn't support timeouts, use default builder - reqwest::Client::builder() + baml_http::Client::builder() .build() .context("Failed to create reqwest client") } else { let danger_accept_invalid_certs = matches!(std::env::var("DANGER_ACCEPT_INVALID_CERTS").as_deref(), Ok("1")); - let mut builder = reqwest::Client::builder() + let mut builder = baml_http::Client::builder() .danger_accept_invalid_certs(danger_accept_invalid_certs) .http2_keep_alive_interval(Some(Duration::from_secs(10))) // To prevent stalling in python, we set the pool to 0 and idle timeout to 0. @@ -71,7 +71,7 @@ pub fn create_http_client( } } -pub(crate) fn create_tracing_client() -> Result { +pub(crate) fn create_tracing_client() -> Result { cfg_if::cfg_if! { if #[cfg(target_arch = "wasm32")] { let cb = builder(); diff --git a/engine/baml-runtime/src/runtime/mod.rs b/engine/baml-runtime/src/runtime/mod.rs index ed423a171e..b0722bb586 100644 --- a/engine/baml-runtime/src/runtime/mod.rs +++ b/engine/baml-runtime/src/runtime/mod.rs @@ -1,4 +1,5 @@ mod ir_features; +#[cfg(feature = "studio")] mod publisher; use std::{ @@ -7,6 +8,7 @@ use std::{ }; use anyhow::Result; +#[cfg(feature = "studio")] pub(super) use publisher::AstSignatureWrapper; cfg_if::cfg_if!( diff --git a/engine/baml-runtime/src/sse.rs b/engine/baml-runtime/src/sse.rs index e078e39781..780df791ef 100644 --- a/engine/baml-runtime/src/sse.rs +++ b/engine/baml-runtime/src/sse.rs @@ -125,11 +125,11 @@ impl Decoder { } } -/// Decode a byte stream (e.g. `reqwest::Response::bytes_stream()`) into a stream +/// Decode a byte stream (e.g. `baml_http::Response::bytes_stream()`) into a stream /// of SSE events. Transport errors are surfaced unchanged. -pub fn eventsource(stream: S) -> impl Stream> +pub fn eventsource(stream: S) -> impl Stream> where - S: Stream>, + S: Stream>, { struct State { inner: std::pin::Pin>, diff --git a/engine/baml-runtime/src/test_executor/mod.rs b/engine/baml-runtime/src/test_executor/mod.rs index 2d119432f5..dd753a1443 100644 --- a/engine/baml-runtime/src/test_executor/mod.rs +++ b/engine/baml-runtime/src/test_executor/mod.rs @@ -143,8 +143,7 @@ impl RenderTestExecutionStatus for AggregateRenderer { } async fn file_reader(path: String) -> Result> { - let file_path = async_std::path::PathBuf::from(&path); - let file_content = async_std::fs::read(file_path).await?; + let file_content = tokio::fs::read(&path).await?; Ok(file_content) } diff --git a/engine/baml-runtime/src/tracing/api_wrapper/mod.rs b/engine/baml-runtime/src/tracing/api_wrapper/mod.rs index a6857909c5..db5c98f29b 100644 --- a/engine/baml-runtime/src/tracing/api_wrapper/mod.rs +++ b/engine/baml-runtime/src/tracing/api_wrapper/mod.rs @@ -91,7 +91,7 @@ pub(super) struct CompleteAPIConfig { pub log_redaction_placeholder: String, pub max_log_chunk_chars: usize, - client: reqwest::Client, + client: baml_http::Client, } impl PartialEq for CompleteAPIConfig { diff --git a/engine/baml-runtime/src/tracingv2/mod.rs b/engine/baml-runtime/src/tracingv2/mod.rs index bb59e0c85d..daef8730fb 100644 --- a/engine/baml-runtime/src/tracingv2/mod.rs +++ b/engine/baml-runtime/src/tracingv2/mod.rs @@ -1,2 +1,3 @@ +#[cfg(feature = "studio")] pub mod publisher; pub mod storage; diff --git a/engine/baml-runtime/src/tracingv2/publisher/publisher.rs b/engine/baml-runtime/src/tracingv2/publisher/publisher.rs index bd0b96d8e7..2c13cc949a 100644 --- a/engine/baml-runtime/src/tracingv2/publisher/publisher.rs +++ b/engine/baml-runtime/src/tracingv2/publisher/publisher.rs @@ -100,7 +100,7 @@ struct RuntimeAST { #[serde(skip)] env_vars: PublisherEnvVars, #[serde(skip)] - pub client: reqwest::Client, + pub client: baml_http::Client, #[serde(skip)] blob_cache: BlobRefCache, } @@ -136,7 +136,7 @@ impl RuntimeAST { let fut = async { if self.api_key().is_none() { return Err(ApiError::Http { - status: reqwest::StatusCode::UNAUTHORIZED, + status: baml_http::StatusCode::UNAUTHORIZED, body: format!("BOUNDARY_API_KEY is not set for {}", TEndpoint::path()), }); } @@ -190,10 +190,10 @@ impl RuntimeAST { #[derive(thiserror::Error, Debug)] pub enum ApiError { #[error("Transport error: {0}")] - Transport(reqwest::Error), + Transport(baml_http::Error), #[error("HTTP error: {status} {body}")] Http { - status: reqwest::StatusCode, + status: baml_http::StatusCode, body: String, }, #[error("Failed to deserialize response: {0}")] @@ -309,7 +309,7 @@ fn ensure_publisher_started() -> Option<&'static mpsc::Sender> let runtime_ast = Arc::new(RuntimeAST { ast: config.ast.clone(), env_vars: config.env_vars.clone(), - client: reqwest::Client::new(), + client: baml_http::Client::new(), blob_cache: BlobRefCache::with_upload_channel(blob_tx.clone()), }); @@ -655,12 +655,12 @@ impl TracePublisher { .put(upload_url) .json(&payload) .headers({ - let mut headers = reqwest::header::HeaderMap::new(); + let mut headers = baml_http::header::HeaderMap::new(); for (key, value) in upload_metadata.to_map() { let header_name = format!("x-amz-meta-{key}"); if let (Ok(name), Ok(val)) = ( - reqwest::header::HeaderName::from_bytes(header_name.as_bytes()), - reqwest::header::HeaderValue::from_str(&value), + baml_http::header::HeaderName::from_bytes(header_name.as_bytes()), + baml_http::header::HeaderValue::from_str(&value), ) { headers.insert(name, val); } @@ -977,7 +977,7 @@ impl BlobUploader { return Err(e.into()); } }; - if let Ok(parsed_url) = reqwest::Url::parse(&upload_response.s3_presigned_url) { + if let Ok(parsed_url) = baml_http::Url::parse(&upload_response.s3_presigned_url) { log::debug!( "Received blob upload URL host={} path={} ({} blobs excluded)", parsed_url.host_str().unwrap_or_default(), diff --git a/engine/baml-runtime/src/tracingv2/storage/storage.rs b/engine/baml-runtime/src/tracingv2/storage/storage.rs index 8e4fca5c72..975de1c290 100644 --- a/engine/baml-runtime/src/tracingv2/storage/storage.rs +++ b/engine/baml-runtime/src/tracingv2/storage/storage.rs @@ -105,6 +105,7 @@ impl TraceStorage { // event.call_id, // event.content.type_name() // ); + #[cfg(feature = "studio")] if let Err(e) = crate::tracingv2::publisher::publish_trace_event(event.clone()) { log::warn!("Failed to publish trace event: {e:?}"); } diff --git a/engine/cli/Cargo.toml b/engine/cli/Cargo.toml index b509dcfa46..d6aedc07a0 100644 --- a/engine/cli/Cargo.toml +++ b/engine/cli/Cargo.toml @@ -20,99 +20,109 @@ unused_variables = "deny" [dependencies] anyhow.workspace = true -axum.workspace = true -axum-extra = { version = "0.9.3", features = ["erased-json", "typed-header"] } baml-runtime = { path = "../baml-runtime", default-features = false, features = ["internal"] } -language-server = { path = "../language_server", default-features = false } +language-server = { path = "../language_server", default-features = false, optional = true } baml-types.workspace = true baml-log.workspace = true -base64.workspace = true -bstd.workspace = true -bytes.workspace = true -cfg-if.workspace = true clap = { workspace = true, features = ["color", "env"] } -clap-cargo = "0.14.1" -colored.workspace = true -console = "0.15.0" -dashmap.workspace = true -derive_more.workspace = true -dialoguer = "0.11.0" -difference = "2.0.0" -dunce = "1.0.4" -either.workspace = true -env_logger.workspace = true -etcetera = "0.8.0" -futures.workspace = true -http.workspace = true -http-body.workspace = true -indexmap.workspace = true -indicatif = "0.17.8" -indicatif-log-bridge = "0.2.3" -indoc.workspace = true +clap-cargo = "0.12" +ctrlc = "3.4" internal-baml-core.workspace = true -generators-lib = { path = "../generators/utils/generators_lib" } log.workspace = true -open = "5.3.0" -pathdiff = "0.1.0" -rand.workspace = true -reqwest.workspace = true -scopeguard.workspace = true -serde.workspace = true -serde_json.workspace = true -similar = { version = "2.6.0", features = ["inline"] } -strsim.workspace = true -strum.workspace = true -strum_macros.workspace = true tokio = { version = "1", default-features = false, features = [ "macros", "time", ] } -tokio-stream = "0.1.15" -# NOTE(sam): adding this caused a build error, I suspect because tower uses nightly features or something -# tower = "0.5.0" -walkdir.workspace = true -uuid.workspace = true -web-time.workspace = true -static_assertions.workspace = true -mime_guess = "2.0.4" -mime = "0.3.17" - -# For tracing -chrono = "0.4.38" -async-std = "1.12.0" -fastrand = "2.1.0" -test-log = "0.2.16" -infer = "0.16.0" -url = "2.5.2" -shell-escape = "0.1.5" -enum_dispatch = "0.3.13" -jsonwebtoken = "9.3.0" -pretty_assertions = "1.4.0" -sha2 = "0.10.8" -tracing = "0.1.40" -# Valuable is needed to prevent serializing objects using Debug, and instead use Serialize. -# https://github.com/tokio-rs/tracing/issues/1570 -tracing-subscriber = { version = "0.3.18", features = [ - "json", - "env-filter", - "valuable", -] } -ctrlc = "3.4" -# openssl = { version = "0.10", optional = true } +# Boundary Cloud (auth/login/deploy) dependencies, behind the `cloud` feature. +axum = { workspace = true, optional = true } +baml-http = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } +bstd = { workspace = true, optional = true } +console = { version = "0.15.0", optional = true } +derive_more = { workspace = true, optional = true } +dialoguer = { version = "0.11.0", optional = true } +etcetera = { version = "0.8.0", optional = true } +futures = { workspace = true, optional = true } +generators-lib = { path = "../generators/utils/generators_lib", optional = true } +indexmap = { workspace = true, optional = true } +indicatif = { version = "0.17.8", optional = true } +open = { version = "5.3.0", optional = true } +pathdiff = { version = "0.1.0", optional = true } +rand = { workspace = true, optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +sha2 = { version = "0.10.8", optional = true } +similar = { version = "2.6.0", features = ["inline"], optional = true } +web-time = { workspace = true, optional = true } [features] -default = ["bedrock"] +default = [ + "bedrock", + "lsp", + "serve", + "dev", + "repl", + "cloud", + "vertex", + "optimize", + "tui", "studio", +] # AWS Bedrock provider support, forwarded through the runtime stack. On by # default; a parent (e.g. baml-python-ffi) that sets default-features = false on # this crate drops the aws-* dependency tree. -bedrock = ["baml-runtime/bedrock", "language-server/bedrock"] +# NOTE: language-server is a weak ("?") dep here so enabling bedrock does not +# force the LSP (and its ~87-crate tree) back in when lsp is disabled. +bedrock = ["baml-runtime/bedrock", "language-server?/bedrock"] +# Language server (LSP) subcommand. On by default for the standalone `baml` +# binary; a parent that sets default-features = false (e.g. baml-python-ffi) +# drops the entire language-server dependency tree (tonic/prost, a second axum, +# lsp-server/lsp-types, tokio-tungstenite, schemars, tar, ...). +lsp = ["dep:language-server"] +# `baml serve` / `baml dev` (axum HTTP server + file watching, via the runtime). +serve = ["baml-runtime/serve"] +dev = ["baml-runtime/dev", "serve"] +# `baml repl`. +repl = ["baml-runtime/repl"] +# `baml optimize` (GEPA; TUI-driven). +optimize = ["baml-runtime/optimize"] +# Boundary Studio tracing publisher in the runtime. +studio = ["baml-runtime/studio"] +# Interactive terminal UIs (ratatui/crossterm) in the runtime. +tui = ["baml-runtime/tui"] +# Vertex AI provider auth (gcp_auth), forwarded to the runtime. +vertex = ["baml-runtime/vertex"] +# Boundary Cloud commands: `baml auth`, `baml login`, `baml deploy`. +cloud = [ + "dep:axum", + "dep:baml-http", + "dep:base64", + "dep:bstd", + "dep:console", + "dep:derive_more", + "dep:dialoguer", + "dep:etcetera", + "dep:futures", + "dep:generators-lib", + "dep:indexmap", + "dep:indicatif", + "dep:open", + "dep:pathdiff", + "dep:rand", + "dep:serde", + "dep:serde_json", + "dep:sha2", + "dep:similar", + "dep:web-time", +] defaults = [] internal = [] skip-integ-tests = [] -# Kept as a no-op alias: rustls-tls (the default now) already statically links its -# crypto (ring), so there is no system OpenSSL to opt into anymore. -static-ssl = ["reqwest/rustls-tls"] +# Kept as a no-op alias: the HTTP stack (baml-http) statically links its +# crypto (rustls + ring), so there is no system OpenSSL to opt into anymore. +static-ssl = [] +# Statically vendor OpenSSL (forwarded to the runtime). +native-tls-vendored = ["baml-runtime/native-tls-vendored"] [dev-dependencies] assert_cmd = "2" diff --git a/engine/cli/src/api_client.rs b/engine/cli/src/api_client.rs index 26b16277eb..8b8d7e6118 100644 --- a/engine/cli/src/api_client.rs +++ b/engine/cli/src/api_client.rs @@ -31,7 +31,7 @@ trait ApiResponse { async fn into_result(self) -> Result; } -impl ApiResponse for reqwest::Response { +impl ApiResponse for baml_http::Response { async fn into_result(self) -> Result { let status = self.status(); if status.is_success() { diff --git a/engine/cli/src/commands.rs b/engine/cli/src/commands.rs index 1169b97baa..48e57f1da3 100644 --- a/engine/cli/src/commands.rs +++ b/engine/cli/src/commands.rs @@ -33,21 +33,26 @@ pub(crate) enum Commands { #[command(about = "Checks for errors and warnings in the baml_src directory")] Check(baml_runtime::cli::check::CheckArgs), + #[cfg(feature = "serve")] #[command(about = "Starts a server that translates LLM responses to BAML responses")] Serve(baml_runtime::cli::serve::ServeArgs), + #[cfg(feature = "dev")] #[command(about = "Starts a development server")] Dev(baml_runtime::cli::dev::DevArgs), + #[cfg(feature = "cloud")] #[command(subcommand, about = "Authenticate with Boundary Cloud", hide = true)] Auth(crate::auth::AuthCommands), + #[cfg(feature = "cloud")] #[command( about = "Login to Boundary Cloud (alias for `baml auth login`)", hide = true )] Login(crate::auth::LoginArgs), + #[cfg(feature = "cloud")] #[command(about = "Deploy a BAML project to Boundary Cloud", hide = true)] Deploy(crate::deploy::DeployArgs), @@ -63,12 +68,15 @@ pub(crate) enum Commands { #[command(about = "Print Bytecode from BAML files", hide = true)] DumpBytecode(baml_runtime::cli::dump_intermediate::DumpIntermediateArgs), + #[cfg(feature = "lsp")] #[command(about = "Starts a language server", name = "lsp")] LanguageServer(crate::lsp::LanguageServerArgs), + #[cfg(feature = "repl")] #[command(about = "Start an interactive REPL for BAML expressions", hide = true)] Repl(baml_runtime::cli::repl::ReplArgs), + #[cfg(feature = "optimize")] #[command(about = "Optimize prompts using GEPA algorithm")] Optimize(baml_runtime::cli::optimize::OptimizeArgs), } @@ -172,6 +180,7 @@ impl RuntimeCli { Ok(crate::ExitCode::Other) } }, + #[cfg(feature = "serve")] Commands::Serve(args) => { args.from = BamlRuntime::parse_baml_src_path(&args.from)?; match args.run(feature_flags.clone()) { @@ -182,6 +191,7 @@ impl RuntimeCli { } } } + #[cfg(feature = "dev")] Commands::Dev(args) => { args.from = BamlRuntime::parse_baml_src_path(&args.from)?; match args.run(defaults, feature_flags.clone()) { @@ -192,6 +202,7 @@ impl RuntimeCli { } } } + #[cfg(feature = "cloud")] Commands::Auth(args) => match t.block_on(async { args.run_async().await }) { Ok(()) => Ok(crate::ExitCode::Success), Err(e) => { @@ -199,6 +210,7 @@ impl RuntimeCli { Ok(crate::ExitCode::Other) } }, + #[cfg(feature = "cloud")] Commands::Login(args) => match t.block_on(async { args.run_async().await }) { Ok(()) => Ok(crate::ExitCode::Success), Err(e) => { @@ -206,6 +218,7 @@ impl RuntimeCli { Ok(crate::ExitCode::Other) } }, + #[cfg(feature = "cloud")] Commands::Deploy(args) => { args.from = BamlRuntime::parse_baml_src_path(&args.from)?; match t.block_on(async { args.run_async(feature_flags.clone()).await }) { @@ -300,10 +313,12 @@ impl RuntimeCli { } } } + #[cfg(feature = "lsp")] Commands::LanguageServer(args) => match args.run() { Ok(()) => Ok(crate::ExitCode::Success), Err(_) => Ok(crate::ExitCode::Other), }, + #[cfg(feature = "repl")] Commands::Repl(args) => match t.block_on(async { args.run().await }) { Ok(()) => Ok(crate::ExitCode::Success), Err(e) => { @@ -311,6 +326,7 @@ impl RuntimeCli { Ok(crate::ExitCode::Other) } }, + #[cfg(feature = "optimize")] Commands::Optimize(args) => { match t.block_on(async { args.run(feature_flags.clone()).await }) { Ok(baml_runtime::cli::optimize::OptimizeRunResult::Success) => { diff --git a/engine/cli/src/lib.rs b/engine/cli/src/lib.rs index dd381f40e9..9e641ef9a5 100644 --- a/engine/cli/src/lib.rs +++ b/engine/cli/src/lib.rs @@ -1,11 +1,18 @@ +#[cfg(feature = "cloud")] pub(crate) mod api_client; +#[cfg(feature = "cloud")] pub(crate) mod auth; +#[cfg(feature = "cloud")] pub(crate) mod colordiff; pub(crate) mod commands; +#[cfg(feature = "cloud")] pub(crate) mod deploy; pub(crate) mod format; +#[cfg(feature = "lsp")] pub(crate) mod lsp; +#[cfg(feature = "cloud")] pub(crate) mod propelauth; +#[cfg(feature = "cloud")] pub(crate) mod tui; use anyhow::Result; diff --git a/engine/cli/src/propelauth.rs b/engine/cli/src/propelauth.rs index 1c240e1937..a0ca1d0884 100644 --- a/engine/cli/src/propelauth.rs +++ b/engine/cli/src/propelauth.rs @@ -2,13 +2,13 @@ use std::time::Duration; use anyhow::{Context, Result}; use axum::{extract::Query, routing::get, Router}; +use baml_http::RequestBuilder; use base64::{engine::general_purpose, Engine as _}; use derive_more::Constructor; use dialoguer::{theme::ColorfulTheme, Confirm}; use etcetera::AppStrategy; use indexmap::IndexMap; use rand::{distributions::Alphanumeric, thread_rng, Rng}; -use reqwest::RequestBuilder; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tokio::{net::TcpListener, sync::mpsc}; @@ -27,7 +27,7 @@ const PROPELAUTH_CLI_REDIRECT_ADDR: &str = "127.0.0.1:24000"; pub(crate) struct PropelAuthClient { auth_url: String, client_id: String, - client: reqwest::Client, + client: baml_http::Client, } /// The result of exchanging an authorization code for an access and refresh token. @@ -117,7 +117,7 @@ impl PropelAuthClient { let state = format!("csrf-state-{}", generate_code_verifier()); // Construct the authorization URL - let auth_url = reqwest::Url::parse_with_params( + let auth_url = baml_http::Url::parse_with_params( &format!("{}/propelauth/oauth/authorize", self.auth_url), &[ ("redirect_uri", redirect_uri.as_str()), diff --git a/engine/language_client_cffi/Cargo.toml b/engine/language_client_cffi/Cargo.toml index b82c9c885a..692851f951 100644 --- a/engine/language_client_cffi/Cargo.toml +++ b/engine/language_client_cffi/Cargo.toml @@ -36,8 +36,32 @@ dashmap = "6.1" [features] # AWS Bedrock provider support, forwarded through the runtime stack. On by # default; build with --no-default-features to drop the aws-* dependency tree. -default = ["bedrock"] +default = [ + "bedrock", + "lsp", + "serve", + "dev", + "repl", + "cloud", + "vertex", + "optimize", + "tui", + "studio", +] bedrock = ["baml-runtime/bedrock", "baml-cli/bedrock"] +# Forwarded baml-cli surface; all on by default so the published package is +# unchanged. Minimal/vendored builds use --no-default-features. +lsp = ["baml-cli/lsp"] +serve = ["baml-cli/serve"] +dev = ["baml-cli/dev"] +repl = ["baml-cli/repl"] +cloud = ["baml-cli/cloud"] +vertex = ["baml-cli/vertex", "baml-runtime/vertex"] +# `baml-cli optimize` and the interactive terminal UIs. +optimize = ["baml-cli/optimize"] +tui = ["baml-cli/tui"] +# Boundary Studio tracing publisher. +studio = ["baml-cli/studio", "baml-runtime/studio"] [build-dependencies] cbindgen = "0.28.0" diff --git a/engine/language_client_python/Cargo.toml b/engine/language_client_python/Cargo.toml index 566e334832..e042b6d8c1 100644 --- a/engine/language_client_python/Cargo.toml +++ b/engine/language_client_python/Cargo.toml @@ -38,7 +38,7 @@ once_cell = "1.19" jsonish = { path = "../baml-lib/jsonish" } log.workspace = true # Consult https://pyo3.rs/main/migration for migration instructions -pyo3 = { version = "0.23.3", default-features = false, features = [ +pyo3 = { version = "0.28", default-features = false, features = [ "abi3-py38", "extension-module", "generate-import-lib", @@ -46,7 +46,7 @@ pyo3 = { version = "0.23.3", default-features = false, features = [ "serde", "either", ] } -pyo3-async-runtimes = { version = "0.23", features = [ +pyo3-async-runtimes = { version = "0.28", features = [ "attributes", "tokio-runtime", ] } @@ -65,12 +65,41 @@ either.workspace = true ctrlc = "3.4.5" [build-dependencies] -pyo3-build-config = "0.21.2" +pyo3-build-config = "0.28" [features] -default = ["bedrock"] +default = [ + "bedrock", + "lsp", + "serve", + "dev", + "repl", + "cloud", + "vertex", + "optimize", + "tui", + "studio", +] # AWS Bedrock provider support (forwards to baml-runtime). On by default; build # with --no-default-features to drop the aws-* dependency tree. bedrock = ["baml-runtime/bedrock", "baml-cli/bedrock"] +# `baml-cli lsp` (the language server). +lsp = ["baml-cli/lsp"] +# Statically vendor OpenSSL for portable/hermetic Linux builds. +native-tls-vendored = ["baml-runtime/native-tls-vendored", "baml-cli/native-tls-vendored"] +# `baml-cli serve` / `baml-cli dev` (axum HTTP server + file watching). +serve = ["baml-cli/serve"] +dev = ["baml-cli/dev"] +# `baml-cli repl`. +repl = ["baml-cli/repl"] +# Boundary Cloud commands (`baml-cli auth/login/deploy`). +cloud = ["baml-cli/cloud"] +# Vertex AI provider auth (gcp_auth). +vertex = ["baml-cli/vertex", "baml-runtime/vertex"] +# `baml-cli optimize` and the interactive terminal UIs. +optimize = ["baml-cli/optimize"] +tui = ["baml-cli/tui"] +# Boundary Studio tracing publisher. +studio = ["baml-cli/studio", "baml-runtime/studio"] # Use the THIR interpreter runtime instead of the VM bytecode runtime thir-interpreter = ["baml-runtime/thir-interpreter"] diff --git a/engine/language_client_python/src/abort_controller.rs b/engine/language_client_python/src/abort_controller.rs index 6fdf36004b..bd52ab2b69 100644 --- a/engine/language_client_python/src/abort_controller.rs +++ b/engine/language_client_python/src/abort_controller.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; static OPERATION_TRIGGERS: Lazy> = Lazy::new(DashMap::new); static NEXT_ID: AtomicU32 = AtomicU32::new(1); -#[pyclass(module = "baml_py.baml_py")] +#[pyclass(module = "baml_py.baml_py", from_py_object)] #[derive(Clone)] pub struct AbortController { id: u32, diff --git a/engine/language_client_python/src/errors.rs b/engine/language_client_python/src/errors.rs index 3aec3ab928..53f7c59aba 100644 --- a/engine/language_client_python/src/errors.rs +++ b/engine/language_client_python/src/errors.rs @@ -24,7 +24,7 @@ fn raise_baml_validation_error( raw_output: String, detailed_message: String, ) -> PyErr { - Python::with_gil(|py| { + Python::attach(|py| { let internal_monkeypatch = py.import("baml_py.internal_monkeypatch").unwrap(); let exception = internal_monkeypatch.getattr("BamlValidationError").unwrap(); let args = (prompt, message, raw_output, detailed_message); @@ -40,7 +40,7 @@ fn raise_baml_client_http_error( status_code: u16, detailed_message: String, ) -> PyErr { - Python::with_gil(|py| { + Python::attach(|py| { let internal_monkeypatch = py.import("baml_py.internal_monkeypatch").unwrap(); let exception = internal_monkeypatch.getattr("BamlClientHttpError").unwrap(); let args = (client_name, message, status_code, detailed_message); @@ -57,7 +57,7 @@ fn raise_baml_client_finish_reason_error( finish_reason: Option, detailed_message: String, ) -> PyErr { - Python::with_gil(|py| { + Python::attach(|py| { let internal_monkeypatch = py.import("baml_py.internal_monkeypatch").unwrap(); let exception = internal_monkeypatch .getattr("BamlClientFinishReasonError") @@ -70,7 +70,7 @@ fn raise_baml_client_finish_reason_error( #[allow(non_snake_case)] fn raise_baml_timeout_error(client_name: String, message: String) -> PyErr { - Python::with_gil(|py| { + Python::attach(|py| { let internal_monkeypatch = py.import("baml_py.internal_monkeypatch").unwrap(); let exception = internal_monkeypatch.getattr("BamlTimeoutError").unwrap(); let args = (client_name, message); diff --git a/engine/language_client_python/src/lib.rs b/engine/language_client_python/src/lib.rs index 7ee26c8368..f67be9e39d 100644 --- a/engine/language_client_python/src/lib.rs +++ b/engine/language_client_python/src/lib.rs @@ -5,6 +5,10 @@ mod runtime; mod serde_py; mod types; +// pyo3 0.28 removed the `PyObject` alias from its root; it was always +// `Py`. Re-provide it crate-wide so type signatures are unchanged. +pub(crate) type PyObject = pyo3::Py; + use pyo3::{ prelude::{pyfunction, pymodule, PyAnyMethods, PyModule, PyResult}, types::PyModuleMethods, diff --git a/engine/language_client_python/src/parse_py_type.rs b/engine/language_client_python/src/parse_py_type.rs index bfb5863008..fa32892e8c 100644 --- a/engine/language_client_python/src/parse_py_type.rs +++ b/engine/language_client_python/src/parse_py_type.rs @@ -7,10 +7,13 @@ use pyo3::{ exceptions::{PyRuntimeError, PyTypeError}, prelude::{PyAnyMethods, PyTypeMethods}, types::{PyBool, PyBoolMethods, PyDict, PyDictMethods, PyList}, - IntoPyObjectExt, PyErr, PyObject, PyResult, Python, + IntoPyObjectExt, PyErr, PyResult, Python, }; -use crate::types::{BamlAudioPy, BamlImagePy, BamlPdfPy, BamlVideoPy}; +use crate::{ + types::{BamlAudioPy, BamlImagePy, BamlPdfPy, BamlVideoPy}, + PyObject, +}; struct SerializationError { position: Vec, @@ -209,7 +212,7 @@ pub fn parse_py_type( any: PyObject, serialize_unknown_types_as_str: bool, ) -> PyResult> { - Python::with_gil(|py| { + Python::attach(|py| { let enum_type = py.import("enum").and_then(|m| m.getattr("Enum"))?; let pydantic = py.import("pydantic")?; let base_model = pydantic.getattr("BaseModel")?; @@ -274,7 +277,7 @@ pub fn parse_py_type( // Get extra fields (like if this is a @@dynamic class) if let Ok(extra) = any.getattr(py, "__pydantic_extra__") { - if let Ok(extra_dict) = extra.downcast_bound::(py) { + if let Ok(extra_dict) = extra.cast_bound::(py) { for (key, value) in extra_dict.iter() { if let (Ok(key), value) = (key.extract::(), value) { fields.insert(key, value.into_py_any(py)?); @@ -285,7 +288,7 @@ pub fn parse_py_type( Ok(MappedPyType::Class(name, fields)) // use downcast only - } else if let Ok(list) = any.downcast_bound::(py) { + } else if let Ok(list) = any.cast_bound::(py) { let mut items = vec![]; let len = list.len()?; for idx in 0..len { @@ -294,7 +297,7 @@ pub fn parse_py_type( Ok(MappedPyType::List(items)) } else if let Ok(kv) = any.extract::>(py) { Ok(MappedPyType::Map(kv)) - } else if let Ok(b) = any.downcast_bound::(py) { + } else if let Ok(b) = any.cast_bound::(py) { Ok(MappedPyType::Bool(b.is_true())) } else if let Ok(i) = any.extract::(py) { Ok(MappedPyType::Int(i)) @@ -306,16 +309,16 @@ pub fn parse_py_type( Ok(MappedPyType::String(s)) } else if any.is_none(py) { Ok(MappedPyType::None) - } else if let Ok(b) = any.downcast_bound::(py) { + } else if let Ok(b) = any.cast_bound::(py) { let b = b.borrow(); Ok(MappedPyType::BamlMedia(b.inner.clone())) - } else if let Ok(b) = any.downcast_bound::(py) { + } else if let Ok(b) = any.cast_bound::(py) { let b = b.borrow(); Ok(MappedPyType::BamlMedia(b.inner.clone())) - } else if let Ok(b) = any.downcast_bound::(py) { + } else if let Ok(b) = any.cast_bound::(py) { let b = b.borrow(); Ok(MappedPyType::BamlMedia(b.inner.clone())) - } else if let Ok(b) = any.downcast_bound::(py) { + } else if let Ok(b) = any.cast_bound::(py) { let b = b.borrow(); Ok(MappedPyType::BamlMedia(b.inner.clone())) } else if matches!(unknown_type_handler, UnknownTypeHandler::SerializeAsStr) { diff --git a/engine/language_client_python/src/runtime.rs b/engine/language_client_python/src/runtime.rs index e8ad57c537..93abdc0ab1 100644 --- a/engine/language_client_python/src/runtime.rs +++ b/engine/language_client_python/src/runtime.rs @@ -6,9 +6,11 @@ use pyo3::{ prelude::{pymethods, PyResult}, pyclass, types::{PyAnyMethods, PyDict, PyList}, - Bound, IntoPyObjectExt, PyObject, PyRef, Python, + Bound, IntoPyObjectExt, PyRef, Python, }; +use crate::PyObject; + // Type alias for pickle reduce return type type PickleReduceResult = PyResult<( PyObject, @@ -48,7 +50,7 @@ crate::lang_wrapper!( ); #[derive(Debug, Clone)] -#[pyclass] +#[pyclass(from_py_object)] pub struct BamlLogEvent { pub metadata: LogEventMetadata, pub prompt: Option, @@ -59,7 +61,7 @@ pub struct BamlLogEvent { } #[derive(Debug, Clone)] -#[pyclass] +#[pyclass(from_py_object)] pub struct LogEventMetadata { pub event_id: String, pub parent_id: Option, @@ -130,7 +132,7 @@ fn extract_handlers_recursive( // Extract block handlers from this level if let Ok(block_bound) = bindings.getattr("block") { - if let Ok(block_list) = block_bound.downcast::() { + if let Ok(block_list) = block_bound.cast::() { for handler in block_list { if let Ok(h) = handler.into_py_any(py) { block_handlers.push(Arc::new(h)); @@ -141,10 +143,10 @@ fn extract_handlers_recursive( // Extract var handlers from this level if let Ok(vars_bound) = bindings.getattr("vars") { - if let Ok(vars_dict) = vars_bound.downcast::() { + if let Ok(vars_dict) = vars_bound.cast::() { for (key, value) in vars_dict { if let Ok(var_name) = key.extract::() { - if let Ok(handler_list) = value.downcast::() { + if let Ok(handler_list) = value.cast::() { let handlers: Vec> = handler_list .into_iter() .filter_map(|h| h.into_py_any(py).ok().map(Arc::new)) @@ -162,10 +164,10 @@ fn extract_handlers_recursive( // Extract stream handlers from this level if let Ok(streams_bound) = bindings.getattr("streams") { - if let Ok(streams_dict) = streams_bound.downcast::() { + if let Ok(streams_dict) = streams_bound.cast::() { for (key, value) in streams_dict { if let Ok(var_name) = key.extract::() { - if let Ok(handler_list) = value.downcast::() { + if let Ok(handler_list) = value.cast::() { let handlers: Vec> = handler_list .into_iter() .filter_map(|h| h.into_py_any(py).ok().map(Arc::new)) @@ -183,7 +185,7 @@ fn extract_handlers_recursive( // Recursively extract from nested functions if let Ok(functions_bound) = bindings.getattr("functions") { - if let Ok(functions_dict) = functions_bound.downcast::() { + if let Ok(functions_dict) = functions_bound.cast::() { for (key, value) in functions_dict { if let Ok(_child_fn_name) = key.extract::() { // Recursively extract from child function's bindings @@ -363,7 +365,7 @@ impl BamlRuntime { pyo3_async_runtimes::tokio::future_into_py(py, async move { let watch_handler = shared_handler(move |notification| { if let Some(ref callbacks) = notification_callbacks { - Python::with_gil(|py| { + Python::attach(|py| { match notification.value { baml_compiler::watch::WatchBamlValue::Value(value) => { if let Some(var_name) = ¬ification.variable_name { @@ -562,10 +564,10 @@ impl BamlRuntime { None }; - let (result, _event_id) = py.allow_threads(|| { + let (result, _event_id) = py.detach(|| { let watch_handler = shared_handler(move |event| { if let Some(ref callbacks) = notification_callbacks { - Python::with_gil(|py| { + Python::attach(|py| { match event.value { baml_compiler::watch::WatchBamlValue::Value(value) => { if let Some(var_name) = &event.variable_name { @@ -903,7 +905,7 @@ impl BamlRuntime { // TODO: Figure out if this will be async or not (images, media, etc). // If it's not async then skip gil and threads. - let result = py.allow_threads(|| { + let result = py.detach(|| { self.inner.build_request_sync( function_name, &args_map, @@ -993,7 +995,7 @@ impl BamlRuntime { baml_runtime .as_ref() .set_log_event_callback(Some(Box::new(move |log_event| { - Python::with_gil(|py| { + Python::attach(|py| { match arc_callback.call1( py, (BamlLogEvent { diff --git a/engine/language_client_python/src/serde_py.rs b/engine/language_client_python/src/serde_py.rs index caad768ab2..a39952e967 100644 --- a/engine/language_client_python/src/serde_py.rs +++ b/engine/language_client_python/src/serde_py.rs @@ -57,7 +57,7 @@ pub fn py_to_json(obj: &Bound<'_, PyAny>) -> PyResult { } // `bool` must be checked before the integer extractions because in Python // `bool` is a subclass of `int`. - if let Ok(b) = obj.downcast::() { + if let Ok(b) = obj.cast::() { return Ok(Value::Bool(b.is_true())); } if let Ok(i) = obj.extract::() { @@ -74,21 +74,21 @@ pub fn py_to_json(obj: &Bound<'_, PyAny>) -> PyResult { if let Ok(s) = obj.extract::() { return Ok(Value::String(s)); } - if let Ok(dict) = obj.downcast::() { + if let Ok(dict) = obj.cast::() { let mut map = serde_json::Map::with_capacity(dict.len()); for (k, v) in dict.iter() { map.insert(k.extract::()?, py_to_json(&v)?); } return Ok(Value::Object(map)); } - if let Ok(list) = obj.downcast::() { + if let Ok(list) = obj.cast::() { let mut arr = Vec::with_capacity(list.len()); for item in list.iter() { arr.push(py_to_json(&item)?); } return Ok(Value::Array(arr)); } - if let Ok(tuple) = obj.downcast::() { + if let Ok(tuple) = obj.cast::() { let mut arr = Vec::with_capacity(tuple.len()); for item in tuple.iter() { arr.push(py_to_json(&item)?); diff --git a/engine/language_client_python/src/types/audio.rs b/engine/language_client_python/src/types/audio.rs index 659df04960..7253d445a1 100644 --- a/engine/language_client_python/src/types/audio.rs +++ b/engine/language_client_python/src/types/audio.rs @@ -2,11 +2,11 @@ use baml_types::BamlMediaContent; use pyo3::{ prelude::{pymethods, PyResult}, types::{PyTuple, PyType}, - Bound, PyAny, PyObject, Python, + Bound, PyAny, Python, }; use super::media_repr::{self, UserFacingBamlMedia}; -use crate::errors::BamlError; +use crate::{errors::BamlError, PyObject}; crate::lang_wrapper!(BamlAudioPy, baml_types::BamlMedia); #[pymethods] diff --git a/engine/language_client_python/src/types/client_registry.rs b/engine/language_client_python/src/types/client_registry.rs index 619792d547..b2c8d146d1 100644 --- a/engine/language_client_python/src/types/client_registry.rs +++ b/engine/language_client_python/src/types/client_registry.rs @@ -4,10 +4,10 @@ use baml_runtime::client_registry; use client_registry::ClientProvider; use pyo3::{ prelude::{pymethods, PyResult}, - IntoPyObjectExt, PyObject, Python, + IntoPyObjectExt, Python, }; -use crate::{errors::BamlInvalidArgumentError, parse_py_type::parse_py_type}; +use crate::{errors::BamlInvalidArgumentError, parse_py_type::parse_py_type, PyObject}; crate::lang_wrapper!(ClientRegistry, client_registry::ClientRegistry); diff --git a/engine/language_client_python/src/types/function_result_stream.rs b/engine/language_client_python/src/types/function_result_stream.rs index f6e998ce9e..cb9d1851eb 100644 --- a/engine/language_client_python/src/types/function_result_stream.rs +++ b/engine/language_client_python/src/types/function_result_stream.rs @@ -2,11 +2,11 @@ use std::collections::HashMap; use pyo3::{ prelude::{pymethods, PyResult}, - PyErr, PyObject, PyRefMut, Python, + PyErr, PyRefMut, Python, }; use super::{function_results::FunctionResult, runtime_ctx_manager::RuntimeContextManager}; -use crate::errors::BamlError; +use crate::{errors::BamlError, PyObject}; crate::lang_wrapper!( FunctionResultStream, @@ -97,7 +97,7 @@ impl FunctionResultStream { let cb = cb.clone_ref(py); move |event| { let partial = FunctionResult::from(event); - let res = Python::with_gil(|py| cb.call1(py, (partial,))).map(|_| ()); + let res = Python::attach(|py| cb.call1(py, (partial,))).map(|_| ()); if let Err(e) = res { log::error!("Error calling on_event callback: {e:?}"); } @@ -107,7 +107,7 @@ impl FunctionResultStream { let on_tick_callback = self.on_tick.as_ref().map(|tick_cb| { let tick_cb = tick_cb.clone_ref(py); move || { - Python::with_gil(|py| { + Python::attach(|py| { let res = tick_cb.call0(py); if let Err(e) = res { e.display(py); @@ -167,10 +167,10 @@ impl SyncFunctionResultStream { }; let on_event = self.on_event.as_ref().map(|cb| { - let cb = Python::with_gil(|py| cb.clone_ref(py)); + let cb = Python::attach(|py| cb.clone_ref(py)); move |event| { let partial = FunctionResult::from(event); - let res = Python::with_gil(|py| cb.call1(py, (partial,))).map(|_| ()); + let res = Python::attach(|py| cb.call1(py, (partial,))).map(|_| ()); if let Err(e) = res { log::error!("Error calling on_event callback: {e:?}"); } @@ -178,9 +178,9 @@ impl SyncFunctionResultStream { }); let on_tick_callback = self.on_tick.as_ref().map(|tick_cb| { - let tick_cb = Python::with_gil(|py| tick_cb.clone_ref(py)); + let tick_cb = Python::attach(|py| tick_cb.clone_ref(py)); move || { - Python::with_gil(|py| { + Python::attach(|py| { // For now, we pass "Unknown" as the reason // In a full implementation, we'd get the last event from the collector tick_cb.call1(py, ("Unknown", py.None())).ok(); diff --git a/engine/language_client_python/src/types/function_results.rs b/engine/language_client_python/src/types/function_results.rs index 7331578020..ed560339f3 100644 --- a/engine/language_client_python/src/types/function_results.rs +++ b/engine/language_client_python/src/types/function_results.rs @@ -3,11 +3,11 @@ use jsonish::{ResponseBamlValue, ResponseValueMeta}; use pyo3::{ prelude::{pymethods, PyResult}, types::{PyAnyMethods, PyDict, PyDictMethods, PyModule, PyTuple, PyType}, - Bound, IntoPyObject, IntoPyObjectExt, Py, PyAny, PyErr, PyObject, Python, + Bound, IntoPyObject, IntoPyObjectExt, Py, PyAny, PyErr, Python, }; use super::{BamlAudioPy, BamlImagePy, BamlPdfPy, BamlVideoPy}; -use crate::{errors::BamlError, runtime::BamlRuntime}; +use crate::{errors::BamlError, runtime::BamlRuntime, PyObject}; crate::lang_wrapper!(FunctionResult, baml_runtime::FunctionResult); @@ -67,7 +67,7 @@ fn pythonize_checks<'a>( ) -> PyResult> { let dict = PyDict::new(py); let check_class = types_module.getattr("Check")?; - let check_class = check_class.downcast::()?; + let check_class = check_class.cast::()?; checks.iter().try_for_each( |ResponseCheck { name, @@ -360,7 +360,7 @@ pub(crate) fn pythonize_strict( properties_dict.set_item("state", format!("{:?}", completion_state.state))?; // Prepare type parameters for StreamingState[...] - let type_parameters_tuple = PyTuple::new(py, [value_type.as_ref()]).expect("PyTuple::new"); + let type_parameters_tuple = PyTuple::new(py, [value_type]).expect("PyTuple::new"); let class_streaming_state_type_constructor = partial_cls_module .getattr("StreamState") diff --git a/engine/language_client_python/src/types/image.rs b/engine/language_client_python/src/types/image.rs index 7568ebdae7..85091a8344 100644 --- a/engine/language_client_python/src/types/image.rs +++ b/engine/language_client_python/src/types/image.rs @@ -1,11 +1,14 @@ use pyo3::{ prelude::{pymethods, PyResult}, types::{PyTuple, PyType}, - Bound, PyAny, PyObject, Python, + Bound, PyAny, Python, }; use super::media_repr::{self, UserFacingBamlMedia}; -use crate::errors::{BamlError, BamlInvalidArgumentError}; +use crate::{ + errors::{BamlError, BamlInvalidArgumentError}, + PyObject, +}; crate::lang_wrapper!(BamlImagePy, baml_types::BamlMedia); #[pymethods] diff --git a/engine/language_client_python/src/types/log_collector.rs b/engine/language_client_python/src/types/log_collector.rs index e74626cbbb..4a424b9a71 100644 --- a/engine/language_client_python/src/types/log_collector.rs +++ b/engine/language_client_python/src/types/log_collector.rs @@ -12,6 +12,8 @@ use pyo3::{ }; use serde_json::Value as JsonValue; +use crate::PyObject; + crate::lang_wrapper!( Collector, baml_runtime::tracingv2::storage::storage::Collector, diff --git a/engine/language_client_python/src/types/media_repr.rs b/engine/language_client_python/src/types/media_repr.rs index b4b63ea508..b2d71f98b7 100644 --- a/engine/language_client_python/src/types/media_repr.rs +++ b/engine/language_client_python/src/types/media_repr.rs @@ -5,10 +5,12 @@ use baml_types::{BamlMedia, BamlMediaContent, BamlMediaType, MediaBase64, MediaU use pyo3::{ ffi::c_str, types::{PyAnyMethods, PyModule, PyType}, - Bound, IntoPyObjectExt, PyAny, PyObject, PyResult, Python, + Bound, IntoPyObjectExt, PyAny, PyResult, Python, }; use serde::{Deserialize, Serialize}; +use crate::PyObject; + /// We rely on the serialization and deserialization of this struct for: /// /// - pydantic serialization (JSON->FastAPI->Pydantic->baml_py), so that @@ -84,7 +86,7 @@ pub fn __get_pydantic_core_schema__( _source_type: Bound<'_, PyAny>, _handler: Bound<'_, PyAny>, ) -> PyResult { - Python::with_gil(|py| { + Python::attach(|py| { let code = c_str!( r#" from pydantic_core import core_schema, SchemaValidator diff --git a/engine/language_client_python/src/types/pdf.rs b/engine/language_client_python/src/types/pdf.rs index 49efc2c1ad..9358c4f5af 100644 --- a/engine/language_client_python/src/types/pdf.rs +++ b/engine/language_client_python/src/types/pdf.rs @@ -1,11 +1,14 @@ use pyo3::{ prelude::{pymethods, PyResult}, types::{PyTuple, PyType}, - Bound, PyAny, PyObject, Python, + Bound, PyAny, Python, }; use super::media_repr::{self, UserFacingBamlMedia}; -use crate::errors::{BamlError, BamlInvalidArgumentError}; +use crate::{ + errors::{BamlError, BamlInvalidArgumentError}, + PyObject, +}; crate::lang_wrapper!(BamlPdfPy, baml_types::BamlMedia); #[pymethods] diff --git a/engine/language_client_python/src/types/request.rs b/engine/language_client_python/src/types/request.rs index b11b519ab7..a0558a4d60 100644 --- a/engine/language_client_python/src/types/request.rs +++ b/engine/language_client_python/src/types/request.rs @@ -1,11 +1,11 @@ use pyo3::{ prelude::pymethods, types::{PyByteArray, PyDict, PyDictMethods}, - Py, PyObject, PyResult, Python, + Py, PyResult, Python, }; use super::log_collector::serde_value_to_py; -use crate::errors::BamlError; +use crate::{errors::BamlError, PyObject}; crate::lang_wrapper!( HTTPRequest, diff --git a/engine/language_client_python/src/types/response.rs b/engine/language_client_python/src/types/response.rs index 53d9371e98..c5d817583c 100644 --- a/engine/language_client_python/src/types/response.rs +++ b/engine/language_client_python/src/types/response.rs @@ -1,11 +1,11 @@ use pyo3::{ prelude::{pymethods, Py}, types::{PyDict, PyDictMethods}, - PyObject, PyResult, Python, + PyResult, Python, }; use super::request::HTTPBody; -use crate::{errors::BamlError, types::log_collector::serde_value_to_py}; +use crate::{errors::BamlError, types::log_collector::serde_value_to_py, PyObject}; crate::lang_wrapper!( HTTPResponse, diff --git a/engine/language_client_python/src/types/runtime_ctx_manager.rs b/engine/language_client_python/src/types/runtime_ctx_manager.rs index a7c9f34360..5ae950db60 100644 --- a/engine/language_client_python/src/types/runtime_ctx_manager.rs +++ b/engine/language_client_python/src/types/runtime_ctx_manager.rs @@ -1,9 +1,9 @@ use pyo3::{ prelude::{pymethods, PyResult}, - IntoPyObjectExt, PyObject, Python, + IntoPyObjectExt, Python, }; -use crate::{errors::BamlError, parse_py_type::parse_py_type}; +use crate::{errors::BamlError, parse_py_type::parse_py_type, PyObject}; crate::lang_wrapper!(RuntimeContextManager, baml_runtime::RuntimeContextManager); diff --git a/engine/language_client_python/src/types/span.rs b/engine/language_client_python/src/types/span.rs index 9f85f2fc6c..958dc8775e 100644 --- a/engine/language_client_python/src/types/span.rs +++ b/engine/language_client_python/src/types/span.rs @@ -5,7 +5,7 @@ use baml_types::BamlValue; use pyo3::{ prelude::{pymethods, PyResult}, types::{PyAnyMethods, PyTypeMethods}, - IntoPyObjectExt, PyObject, Python, + IntoPyObjectExt, Python, }; use super::runtime_ctx_manager::RuntimeContextManager; @@ -13,6 +13,7 @@ use crate::{ errors::{BamlError, BamlInvalidArgumentError}, parse_py_type::parse_py_type, runtime::BamlRuntime, + PyObject, }; crate::lang_wrapper!( diff --git a/engine/language_client_python/src/types/type_builder.rs b/engine/language_client_python/src/types/type_builder.rs index e73bf084f2..f36d30600f 100644 --- a/engine/language_client_python/src/types/type_builder.rs +++ b/engine/language_client_python/src/types/type_builder.rs @@ -3,7 +3,6 @@ use std::ops::Deref; use baml_runtime::type_builder::{self, WithMeta}; use baml_types::{ir_type::UnionConstructor, BamlValue}; use pyo3::{ - prelude::PyAnyMethods, pymethods, types::{PyTuple, PyTupleMethods}, Bound, PyResult, @@ -129,7 +128,7 @@ impl TypeBuilder { let mut rs_types = vec![]; for idx in 0..types.len() { let item = types.get_item(idx)?; - let item = item.downcast::()?; + let item = item.cast::()?; rs_types.push(item.borrow().inner.lock().unwrap().clone()); } Ok(baml_types::TypeIR::union(rs_types).into()) diff --git a/engine/language_client_python/src/types/video.rs b/engine/language_client_python/src/types/video.rs index e3f17c4325..c3f3c7034c 100644 --- a/engine/language_client_python/src/types/video.rs +++ b/engine/language_client_python/src/types/video.rs @@ -1,11 +1,14 @@ use pyo3::{ prelude::{pymethods, PyResult}, types::{PyTuple, PyType}, - Bound, PyAny, PyObject, Python, + Bound, PyAny, Python, }; use super::media_repr::{self, UserFacingBamlMedia}; -use crate::errors::{BamlError, BamlInvalidArgumentError}; +use crate::{ + errors::{BamlError, BamlInvalidArgumentError}, + PyObject, +}; crate::lang_wrapper!(BamlVideoPy, baml_types::BamlMedia); #[pymethods] diff --git a/engine/language_client_ruby/ext/ruby_ffi/Cargo.toml b/engine/language_client_ruby/ext/ruby_ffi/Cargo.toml index 7cc0909b21..5426f65cc5 100644 --- a/engine/language_client_ruby/ext/ruby_ffi/Cargo.toml +++ b/engine/language_client_ruby/ext/ruby_ffi/Cargo.toml @@ -47,8 +47,32 @@ tracing-subscriber = { version = "0.3.18", features = [ [features] # AWS Bedrock provider support, forwarded through the runtime stack. On by # default; build with --no-default-features to drop the aws-* dependency tree. -default = ["bedrock"] +default = [ + "bedrock", + "lsp", + "serve", + "dev", + "repl", + "cloud", + "vertex", + "optimize", + "tui", + "studio", +] bedrock = ["baml-runtime/bedrock", "baml-cli/bedrock"] +# Forwarded baml-cli surface; all on by default so the published package is +# unchanged. Minimal/vendored builds use --no-default-features. +lsp = ["baml-cli/lsp"] +serve = ["baml-cli/serve"] +dev = ["baml-cli/dev"] +repl = ["baml-cli/repl"] +cloud = ["baml-cli/cloud"] +vertex = ["baml-cli/vertex", "baml-runtime/vertex"] +# `baml-cli optimize` and the interactive terminal UIs. +optimize = ["baml-cli/optimize"] +tui = ["baml-cli/tui"] +# Boundary Studio tracing publisher. +studio = ["baml-cli/studio", "baml-runtime/studio"] [dev-dependencies.magnus] version = "0.7.1" diff --git a/engine/language_client_typescript/Cargo.toml b/engine/language_client_typescript/Cargo.toml index f5bb4e61f5..c6f1e48ca5 100644 --- a/engine/language_client_typescript/Cargo.toml +++ b/engine/language_client_typescript/Cargo.toml @@ -55,9 +55,33 @@ tracing-subscriber = { version = "0.3.18", features = [ napi-build = "2.2.3" [features] -default = ["bedrock"] +default = [ + "bedrock", + "lsp", + "serve", + "dev", + "repl", + "cloud", + "vertex", + "optimize", + "tui", + "studio", +] # AWS Bedrock provider support, forwarded through the runtime stack. On by # default; build with --no-default-features to drop the aws-* dependency tree. bedrock = ["baml-runtime/bedrock", "baml-cli/bedrock"] +# Forwarded baml-cli surface; all on by default so the published package is +# unchanged. Minimal/vendored builds use --no-default-features. +lsp = ["baml-cli/lsp"] +serve = ["baml-cli/serve"] +dev = ["baml-cli/dev"] +repl = ["baml-cli/repl"] +cloud = ["baml-cli/cloud"] +vertex = ["baml-cli/vertex", "baml-runtime/vertex"] +# `baml-cli optimize` and the interactive terminal UIs. +optimize = ["baml-cli/optimize"] +tui = ["baml-cli/tui"] +# Boundary Studio tracing publisher. +studio = ["baml-cli/studio", "baml-runtime/studio"] # Use the THIR interpreter runtime instead of the VM bytecode runtime thir-interpreter = ["baml-runtime/thir-interpreter"] diff --git a/engine/scripts/vendor-audit-coverage.py b/engine/scripts/vendor-audit-coverage.py new file mode 100644 index 0000000000..498e59dda3 --- /dev/null +++ b/engine/scripts/vendor-audit-coverage.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Cross-reference the vendor-profile crate set against the largest public +cargo-vet audit aggregation (https://github.com/google/rust-crate-audits). + +When vendoring, a crate whose exact version is covered by a published audit +(directly or via a full-audit + delta-audit chain) is likely importable with +low friction; a crate with no published audit is the real review burden. + +Usage (from engine/): + cargo tree -p baml-python-ffi --no-default-features -e normal --prefix none \ + | sed -E 's/ \\(\\*\\)//; s/ \\(proc-macro\\)//' \ + | grep -v "$(pwd)" | awk '{print $1" "$2}' | sort -u > /tmp/vendor-crates.txt + curl -sL -o /tmp/google-audits.toml \ + https://raw.githubusercontent.com/google/rust-crate-audits/main/audits.toml + python3 scripts/vendor-audit-coverage.py /tmp/vendor-crates.txt /tmp/google-audits.toml +""" + +import collections +import sys +import tomllib + + +def main(crates_path: str, audits_path: str) -> None: + with open(audits_path, "rb") as f: + data = tomllib.load(f) + + audits = data.get("audits", {}) + trusted = data.get("trusted", {}) + + full = collections.defaultdict(set) + deltas = collections.defaultdict(list) + for crate, entries in audits.items(): + for e in entries: + if "version" in e: + full[crate].add(e["version"]) + elif "delta" in e: + a, b = [x.strip() for x in e["delta"].split("->")] + deltas[crate].append((a, b)) + + def reachable(crate: str, version: str) -> bool: + seen = set(full[crate]) + changed = True + while changed and version not in seen: + changed = False + for a, b in deltas[crate]: + if a in seen and b not in seen: + seen.add(b) + changed = True + return version in seen + + ours = set() + for line in open(crates_path): + parts = line.split() + if len(parts) == 2 and parts[1].startswith("v"): + ours.add((parts[0], parts[1][1:])) + + exact, trusted_only, older, none = [], [], [], [] + for name, ver in sorted(ours): + if name in audits and reachable(name, ver): + exact.append((name, ver)) + elif name in trusted: + trusted_only.append((name, ver)) + elif name in audits: + have = sorted(full[name] | {b for _, b in deltas[name]}) + older.append((name, ver, have[-1] if have else "?")) + else: + none.append((name, ver)) + + total = len(ours) + print(f"Vendor profile crates: {total}") + print(f" Exact version has published audit: {len(exact):4d} ({100 * len(exact) // total}%)") + print(f" Trusted publisher (any version): {len(trusted_only):4d}") + print(f" Audited, but different version: {len(older):4d}") + print(f" No published audit: {len(none):4d}") + print("\n=== No published audit (the real review burden) ===") + for n, v in none: + print(f" {n} v{v}") + print("\n=== Version mismatch (latest audited version shown) ===") + for n, v, latest in older: + print(f" {n}: ours v{v}, audited up to {latest}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(__doc__) + sys.exit(1) + main(sys.argv[1], sys.argv[2]) diff --git a/engine/vendored/gcp-auth/Cargo.toml b/engine/vendored/gcp-auth/Cargo.toml new file mode 100644 index 0000000000..8322089da9 --- /dev/null +++ b/engine/vendored/gcp-auth/Cargo.toml @@ -0,0 +1,58 @@ +# Vendored fork of gcp_auth 0.12.3 (https://github.com/djc/gcp_auth), MIT. +# +# Changes from upstream, to keep the `ring` crate out of the dependency tree +# (required for monorepos that ban ring and mandate a system/BoringSSL crypto +# stack): +# - HTTPS client: hyper-rustls -> hyper-tls (platform/native TLS). +# - JWT RS256 signing: ring::signature -> the pure-Rust `rsa` + `sha2` crates. +# Both changes are localized to src/types.rs. To use a different signing backend +# (e.g. BoringSSL), replace the `Signer` impl in that file. +[package] +edition = "2021" +rust-version = "1.70" +name = "gcp_auth" +version = "0.12.3" +description = "GCP authentication (vendored fork; ring-free)" +readme = "README.md" +license = "MIT" +repository = "https://github.com/djc/gcp_auth" + +[lib] +name = "gcp_auth" +path = "src/lib.rs" + +[dependencies] +async-trait = "0.1" +base64 = "0.22" +bytes = "1" +chrono = { version = "0.4.31", features = ["serde"] } +home = "0.5.5" +http = "1" +http-body-util = "0.1" +hyper = { version = "1", default-features = false, features = [ + "client", + "http1", + "http2", +] } +hyper-tls = "0.6" +native-tls = { version = "0.2", features = ["alpn"] } +tokio-native-tls = "0.3" +hyper-util = { version = "0.1.4", features = [ + "client-legacy", + "http1", + "http2", + "tokio", +] } +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "0.10" +rustls-pemfile = "2" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +tokio = { version = "1.1", features = ["fs", "sync"] } +tracing = "0.1.29" +tracing-futures = "0.2.5" +url = "2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/engine/vendored/gcp-auth/LICENSE b/engine/vendored/gcp-auth/LICENSE new file mode 100644 index 0000000000..6d87471b8d --- /dev/null +++ b/engine/vendored/gcp-auth/LICENSE @@ -0,0 +1,63 @@ +Copyright (c) 2020 Peter Hrvola + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +[yup-oauth2] + +Copyright (c) 2015 The yup-oauth2 Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +[JWT] + +Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official +Google product or in any way related! + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS \ No newline at end of file diff --git a/engine/vendored/gcp-auth/README.md b/engine/vendored/gcp-auth/README.md new file mode 100644 index 0000000000..c939f249c0 --- /dev/null +++ b/engine/vendored/gcp-auth/README.md @@ -0,0 +1,51 @@ +# GCP Auth + +[![Crates.io][crates-badge]][crates-url] +[![Documentation][docs-badge]][docs-url] +[![MIT licensed][mit-badge]][mit-url] + +[crates-badge]: https://img.shields.io/crates/v/gcp_auth.svg +[crates-url]: https://crates.io/crates/gcp_auth +[docs-badge]: https://docs.rs/gcp_auth/badge.svg +[docs-url]: https://docs.rs/gcp_auth +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: LICENSE + +GCP auth provides authentication using service accounts Google Cloud Platform (GCP) + +GCP auth is a simple, minimal authentication library for Google Cloud Platform (GCP) +providing authentication using service accounts. Once authenticated, the service +account can be used to acquire bearer tokens for use in authenticating against GCP +services. + +The library supports the following methods of retrieving tokens in the listed priority order: + +1. Reading custom service account credentials from the path pointed to by the + `GOOGLE_APPLICATION_CREDENTIALS` environment variable. Alternatively, custom service + account credentials can be read from a JSON file or string. +2. Look for credentials in `.config/gcloud/application_default_credentials.json`; + if found, use these credentials to request refresh tokens. This file can be created + by invoking `gcloud auth application-default login`. +3. Use the default service account by retrieving a token from the metadata server. +4. Retrieving a token from the `gcloud` CLI tool, if it is available on the `PATH`. + +For more detailed information and examples, see the [docs][docs-url]. + +This crate does not currently support Windows. + +## Simple usage + +The default way to use this library is to select the appropriate token provider using `provider()`. It will +find the appropriate authentication method and use it to retrieve tokens. + +```rust,no_run +let provider = gcp_auth::provider().await?; +let scopes = &["https://www.googleapis.com/auth/cloud-platform"]; +let token = provider.token(scopes).await?; +``` + +# License + +Parts of the implementation have been sourced from [yup-oauth2](https://github.com/dermesser/yup-oauth2). + +Licensed under [MIT license](http://opensource.org/licenses/MIT). diff --git a/engine/vendored/gcp-auth/src/config_default_credentials.rs b/engine/vendored/gcp-auth/src/config_default_credentials.rs new file mode 100644 index 0000000000..a679abef4f --- /dev/null +++ b/engine/vendored/gcp-auth/src/config_default_credentials.rs @@ -0,0 +1,136 @@ +use std::{path::PathBuf, sync::Arc}; + +use async_trait::async_trait; +use bytes::Bytes; +use http_body_util::Full; +use hyper::{header::CONTENT_TYPE, Method, Request}; +use serde::Serialize; +use tokio::sync::RwLock; +use tracing::{debug, instrument, Level}; + +use crate::{ + types::{AuthorizedUserRefreshToken, HttpClient, Token}, + Error, TokenProvider, +}; + +/// A token provider that uses the default user credentials +/// +/// Reads credentials from `.config/gcloud/application_default_credentials.json` on Linux and MacOS +/// or from `%APPDATA%/gcloud/application_default_credentials.json` on Windows. +/// See [GCloud Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials#personal) +/// for details. +#[derive(Debug)] +pub struct ConfigDefaultCredentials { + client: HttpClient, + token: RwLock>, + credentials: AuthorizedUserRefreshToken, +} + +impl ConfigDefaultCredentials { + /// Check for user credentials in the default location and try to deserialize them + pub async fn new() -> Result { + let client = HttpClient::new()?; + Self::with_client(&client).await + } + + pub(crate) async fn with_client(client: &HttpClient) -> Result { + debug!("try to load credentials from configuration"); + let mut config_path = config_dir()?; + config_path.push(USER_CREDENTIALS_PATH); + debug!(config = config_path.to_str(), "reading configuration file"); + + let credentials = AuthorizedUserRefreshToken::from_file(&config_path)?; + debug!(project = ?credentials.quota_project_id, client = credentials.client_id, "found user credentials"); + + Ok(Self { + client: client.clone(), + token: RwLock::new(Self::fetch_token(&credentials, client).await?), + credentials, + }) + } + + #[instrument(level = Level::DEBUG, skip(cred, client))] + async fn fetch_token( + cred: &AuthorizedUserRefreshToken, + client: &HttpClient, + ) -> Result, Error> { + client + .token( + &|| { + Request::builder() + .method(Method::POST) + .uri(DEFAULT_TOKEN_GCP_URI) + .header(CONTENT_TYPE, "application/json") + .body(Full::from(Bytes::from( + serde_json::to_vec(&RefreshRequest { + client_id: &cred.client_id, + client_secret: &cred.client_secret, + grant_type: "refresh_token", + refresh_token: &cred.refresh_token, + }) + .unwrap(), + ))) + .unwrap() + }, + "ConfigDefaultCredentials", + ) + .await + } +} + +#[async_trait] +impl TokenProvider for ConfigDefaultCredentials { + async fn token(&self, _scopes: &[&str]) -> Result, Error> { + let token = self.token.read().await.clone(); + if !token.has_expired() { + return Ok(token); + } + + let mut locked = self.token.write().await; + let token = Self::fetch_token(&self.credentials, &self.client).await?; + *locked = token.clone(); + Ok(token) + } + + async fn project_id(&self) -> Result, Error> { + self.credentials + .quota_project_id + .clone() + .ok_or(Error::Str("no project ID in user credentials")) + } +} + +#[derive(Serialize, Debug)] +struct RefreshRequest<'a> { + client_id: &'a str, + client_secret: &'a str, + grant_type: &'a str, + refresh_token: &'a str, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn config_dir() -> Result { + let mut home = home::home_dir().ok_or(Error::Str("home directory not found"))?; + home.push(CONFIG_DIR); + Ok(home) +} + +#[cfg(target_os = "windows")] +fn config_dir() -> Result { + let app_data = std::env::var(ENV_APPDATA) + .map_err(|_| Error::Str("APPDATA environment variable not found"))?; + let config_path = PathBuf::from(app_data); + match config_path.exists() { + true => Ok(config_path), + false => Err(Error::Str("APPDATA directory not found")), + } +} + +const DEFAULT_TOKEN_GCP_URI: &str = "https://accounts.google.com/o/oauth2/token"; +const USER_CREDENTIALS_PATH: &str = "gcloud/application_default_credentials.json"; + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const CONFIG_DIR: &str = ".config"; + +#[cfg(target_os = "windows")] +const ENV_APPDATA: &str = "APPDATA"; diff --git a/engine/vendored/gcp-auth/src/custom_service_account.rs b/engine/vendored/gcp-auth/src/custom_service_account.rs new file mode 100644 index 0000000000..66d37881c1 --- /dev/null +++ b/engine/vendored/gcp-auth/src/custom_service_account.rs @@ -0,0 +1,210 @@ +use std::{collections::HashMap, path::Path, str::FromStr, sync::Arc}; + +use async_trait::async_trait; +use base64::{engine::general_purpose::URL_SAFE, Engine}; +use bytes::Bytes; +use chrono::Utc; +use http_body_util::Full; +use hyper::{header::CONTENT_TYPE, Request}; +use serde::Serialize; +use tokio::sync::RwLock; +use tracing::{debug, instrument, Level}; +use url::form_urlencoded; + +use crate::{ + types::{HttpClient, ServiceAccountKey, Signer, Token}, + Error, TokenProvider, +}; + +/// A custom service account containing credentials +/// +/// Once initialized, a [`CustomServiceAccount`] can be converted into an [`AuthenticationManager`] +/// using the applicable `From` implementation. +/// +/// [`AuthenticationManager`]: crate::AuthenticationManager +#[derive(Debug)] +pub struct CustomServiceAccount { + client: HttpClient, + credentials: ServiceAccountKey, + signer: Signer, + tokens: RwLock, Arc>>, + subject: Option, + audience: Option, +} + +impl CustomServiceAccount { + /// Check `GOOGLE_APPLICATION_CREDENTIALS` environment variable for a path to JSON credentials + pub fn from_env() -> Result, Error> { + debug!("check for GOOGLE_APPLICATION_CREDENTIALS env var"); + match ServiceAccountKey::from_env()? { + Some(credentials) => Self::new(credentials, HttpClient::new()?).map(Some), + None => Ok(None), + } + } + + /// Read service account credentials from the given JSON file + pub fn from_file>(path: T) -> Result { + Self::new(ServiceAccountKey::from_file(path)?, HttpClient::new()?) + } + + /// Read service account credentials from the given JSON string + pub fn from_json(s: &str) -> Result { + Self::new(ServiceAccountKey::from_str(s)?, HttpClient::new()?) + } + + /// Set the `subject` to impersonate a user + pub fn with_subject(mut self, subject: String) -> Self { + self.subject = Some(subject); + self + } + + /// Set the `Audience` to impersonate a user + pub fn with_audience(mut self, audience: String) -> Self { + self.audience = Some(audience); + self + } + + fn new(credentials: ServiceAccountKey, client: HttpClient) -> Result { + debug!(project = ?credentials.project_id, email = credentials.client_email, "found credentials"); + Ok(Self { + client, + signer: Signer::new(&credentials.private_key)?, + credentials, + tokens: RwLock::new(HashMap::new()), + subject: None, + audience: None, + }) + } + + #[instrument(level = Level::DEBUG, skip(self))] + async fn fetch_token(&self, scopes: &[&str]) -> Result, Error> { + let jwt = Claims::new( + &self.credentials, + scopes, + self.subject.as_deref(), + self.audience.as_deref(), + ) + .to_jwt(&self.signer)?; + let body = Bytes::from( + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(&[("grant_type", GRANT_TYPE), ("assertion", jwt.as_str())]) + .finish() + .into_bytes(), + ); + + let token = self + .client + .token( + &|| { + Request::post(&self.credentials.token_uri) + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Full::from(body.clone())) + .unwrap() + }, + "CustomServiceAccount", + ) + .await?; + + Ok(token) + } + + /// The RSA PKCS1 SHA256 [`Signer`] used to sign JWT tokens + pub fn signer(&self) -> &Signer { + &self.signer + } + + /// The project ID as found in the credentials + pub fn project_id(&self) -> Option<&str> { + self.credentials.project_id.as_deref() + } + + /// The private key as found in the credentials + pub fn private_key_pem(&self) -> &str { + &self.credentials.private_key + } +} + +#[async_trait] +impl TokenProvider for CustomServiceAccount { + async fn token(&self, scopes: &[&str]) -> Result, Error> { + let key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + let token = self.tokens.read().await.get(&key).cloned(); + if let Some(token) = token { + if !token.has_expired() { + return Ok(token.clone()); + } + + let mut locked = self.tokens.write().await; + let token = self.fetch_token(scopes).await?; + locked.insert(key, token.clone()); + return Ok(token); + } + + let mut locked = self.tokens.write().await; + let token = self.fetch_token(scopes).await?; + locked.insert(key, token.clone()); + return Ok(token); + } + + async fn project_id(&self) -> Result, Error> { + match &self.credentials.project_id { + Some(pid) => Ok(pid.clone()), + None => Err(Error::Str("no project ID in application credentials")), + } + } +} + +/// Permissions requested for a JWT. +/// See https://developers.google.com/identity/protocols/OAuth2ServiceAccount#authorizingrequests. +#[derive(Serialize, Debug)] +pub(crate) struct Claims<'a> { + iss: &'a str, + aud: &'a str, + exp: i64, + iat: i64, + sub: Option<&'a str>, + scope: String, +} + +impl<'a> Claims<'a> { + pub(crate) fn new( + key: &'a ServiceAccountKey, + scopes: &[&str], + sub: Option<&'a str>, + aud: Option<&'a str>, + ) -> Self { + let mut scope = String::with_capacity(16); + for (i, s) in scopes.iter().enumerate() { + if i != 0 { + scope.push(' '); + } + + scope.push_str(s); + } + + let iat = Utc::now().timestamp(); + Claims { + iss: &key.client_email, + aud: aud.unwrap_or(&key.token_uri), + exp: iat + 3600 - 5, // Max validity is 1h + iat, + sub, + scope, + } + } + + pub(crate) fn to_jwt(&self, signer: &Signer) -> Result { + let mut jwt = String::new(); + URL_SAFE.encode_string(GOOGLE_RS256_HEAD, &mut jwt); + jwt.push('.'); + URL_SAFE.encode_string(serde_json::to_string(self).unwrap(), &mut jwt); + + let signature = signer.sign(jwt.as_bytes())?; + jwt.push('.'); + URL_SAFE.encode_string(&signature, &mut jwt); + Ok(jwt) + } +} + +pub(crate) const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const GOOGLE_RS256_HEAD: &str = r#"{"alg":"RS256","typ":"JWT"}"#; diff --git a/engine/vendored/gcp-auth/src/gcloud_authorized_user.rs b/engine/vendored/gcp-auth/src/gcloud_authorized_user.rs new file mode 100644 index 0000000000..a3120ba487 --- /dev/null +++ b/engine/vendored/gcp-auth/src/gcloud_authorized_user.rs @@ -0,0 +1,131 @@ +use std::{process::Command, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use tokio::sync::RwLock; +use tracing::{debug, instrument}; + +use crate::{types::Token, Error, TokenProvider}; + +/// A token provider that queries the `gcloud` CLI for access tokens +#[derive(Debug)] +pub struct GCloudAuthorizedUser { + project_id: Option>, + token: RwLock>, +} + +impl GCloudAuthorizedUser { + /// Check if `gcloud` is installed and logged in + pub async fn new() -> Result { + debug!("try to print access token via `gcloud`"); + let token = RwLock::new(Self::fetch_token()?); + let project_id = run(&["config", "get-value", "project"]).ok(); + Ok(Self { + project_id: project_id.map(Arc::from), + token, + }) + } + + #[instrument(level = tracing::Level::DEBUG)] + fn fetch_token() -> Result, Error> { + Ok(Arc::new(Token::from_string( + run(&["auth", "print-access-token", "--quiet"])?, + DEFAULT_TOKEN_DURATION, + ))) + } +} + +#[async_trait] +impl TokenProvider for GCloudAuthorizedUser { + async fn token(&self, _scopes: &[&str]) -> Result, Error> { + let token = self.token.read().await.clone(); + if !token.has_expired() { + return Ok(token); + } + + let mut locked = self.token.write().await; + let token = Self::fetch_token()?; + *locked = token.clone(); + Ok(token) + } + + async fn project_id(&self) -> Result, Error> { + self.project_id + .clone() + .ok_or(Error::Str("failed to get project ID from `gcloud`")) + } +} + +fn run(cmd: &[&str]) -> Result { + let mut command = Command::new(GCLOUD_CMD); + command.args(cmd); + + let mut stdout = match command.output() { + Ok(output) if output.status.success() => output.stdout, + Ok(_) => return Err(Error::Str("running `gcloud` command failed")), + Err(err) => return Err(Error::Io("failed to run `gcloud`", err)), + }; + + while let Some(b' ' | b'\r' | b'\n') = stdout.last() { + stdout.pop(); + } + + String::from_utf8(stdout).map_err(|_| Error::Str("output from `gcloud` is not UTF-8")) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +const GCLOUD_CMD: &str = "gcloud"; + +#[cfg(target_os = "windows")] +const GCLOUD_CMD: &str = "gcloud.cmd"; + +/// The default number of seconds that it takes for a Google Cloud auth token to expire. +/// This appears to be the default from practical testing, but we have not found evidence +/// that this will always be the default duration. +pub(crate) const DEFAULT_TOKEN_DURATION: Duration = Duration::from_secs(3600); + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + + #[tokio::test] + #[ignore] + async fn gcloud() { + let gcloud = GCloudAuthorizedUser::new().await.unwrap(); + println!("{:?}", gcloud.project_id); + if let Ok(t) = gcloud.token(&[""]).await { + let expires = Utc::now() + DEFAULT_TOKEN_DURATION; + println!("{:?}", t); + assert!(!t.has_expired()); + assert!(t.expires_at() < expires + Duration::from_secs(1)); + assert!(t.expires_at() > expires - Duration::from_secs(1)); + } else { + panic!("GCloud Authorized User failed to get a token"); + } + } + + /// `gcloud_authorized_user` is the only user type to get a token that isn't deserialized from + /// JSON, and that doesn't include an expiry time. As such, the default token expiry time + /// functionality is tested here. + #[test] + fn test_token_from_string() { + let s = String::from("abc123"); + let token = Token::from_string(s, DEFAULT_TOKEN_DURATION); + let expires = Utc::now() + DEFAULT_TOKEN_DURATION; + + assert_eq!(token.as_str(), "abc123"); + assert!(!token.has_expired()); + assert!(token.expires_at() < expires + Duration::from_secs(1)); + assert!(token.expires_at() > expires - Duration::from_secs(1)); + } + + #[test] + fn test_deserialize_no_time() { + let s = r#"{"access_token":"abc123"}"#; + let result = serde_json::from_str::(s) + .expect_err("Deserialization from JSON should fail when no expiry_time is included"); + + assert!(result.is_data()); + } +} diff --git a/engine/vendored/gcp-auth/src/lib.rs b/engine/vendored/gcp-auth/src/lib.rs new file mode 100644 index 0000000000..d17047542b --- /dev/null +++ b/engine/vendored/gcp-auth/src/lib.rs @@ -0,0 +1,207 @@ +//! GCP auth provides authentication using service accounts Google Cloud Platform (GCP) +//! +//! GCP auth is a simple, minimal authentication library for Google Cloud Platform (GCP) +//! providing authentication using service accounts. Once authenticated, the service +//! account can be used to acquire bearer tokens for use in authenticating against GCP +//! services. +//! +//! The library supports the following methods of retrieving tokens: +//! +//! 1. Reading custom service account credentials from the path pointed to by the +//! `GOOGLE_APPLICATION_CREDENTIALS` environment variable. Alternatively, custom service +//! account credentials can be read from a JSON file or string. +//! 2. Look for credentials in `.config/gcloud/application_default_credentials.json`; +//! if found, use these credentials to request refresh tokens. This file can be created +//! by invoking `gcloud auth application-default login`. +//! 3. Use the default service account by retrieving a token from the metadata server. +//! 4. Retrieving a token from the `gcloud` CLI tool, if it is available on the `PATH`. +//! +//! For more details, see [`provider()`]. +//! +//! A [`TokenProvider`] handles caching tokens for their lifetime; it will not make a request if +//! an appropriate token is already cached. Therefore, the caller should not cache tokens. +//! +//! ## Simple usage +//! +//! The default way to use this library is to select the appropriate token provider using +//! [`provider()`]. It will find the appropriate authentication method and use it to retrieve +//! tokens. +//! +//! ```rust,no_run +//! # async fn get_token() -> Result<(), gcp_auth::Error> { +//! let provider = gcp_auth::provider().await?; +//! let scopes = &["https://www.googleapis.com/auth/cloud-platform"]; +//! let token = provider.token(scopes).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Supplying service account credentials +//! +//! When running outside of GCP (for example, on a development machine), it can be useful to supply +//! service account credentials. The first method checked by [`provider()`] is to +//! read a path to a file containing JSON credentials in the `GOOGLE_APPLICATION_CREDENTIALS` +//! environment variable. However, you may also supply a custom path to read credentials from, or +//! a `&str` containing the credentials. In both of these cases, you should create a +//! [`CustomServiceAccount`] directly using one of its associated functions: +//! +//! ```rust,no_run +//! # use std::path::PathBuf; +//! # +//! # async fn get_token() -> Result<(), gcp_auth::Error> { +//! use gcp_auth::{CustomServiceAccount, TokenProvider}; +//! +//! // `credentials_path` variable is the path for the credentials `.json` file. +//! let credentials_path = PathBuf::from("service-account.json"); +//! let service_account = CustomServiceAccount::from_file(credentials_path)?; +//! let scopes = &["https://www.googleapis.com/auth/cloud-platform"]; +//! let token = service_account.token(scopes).await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! ## Getting tokens in multi-thread or async environments +//! +//! Using a `OnceCell` makes it easy to reuse the [`AuthenticationManager`] across different +//! threads or async tasks. +//! +//! ```rust,no_run +//! use std::sync::Arc; +//! use tokio::sync::OnceCell; +//! use gcp_auth::TokenProvider; +//! +//! static TOKEN_PROVIDER: OnceCell> = OnceCell::const_new(); +//! +//! async fn token_provider() -> &'static Arc { +//! TOKEN_PROVIDER +//! .get_or_init(|| async { +//! gcp_auth::provider() +//! .await +//! .expect("unable to initialize token provider") +//! }) +//! .await +//! } +//! ``` + +#![warn(unreachable_pub)] + +use std::sync::Arc; + +use async_trait::async_trait; +use thiserror::Error; +use tracing::{debug, instrument, Level}; + +mod custom_service_account; +pub use custom_service_account::CustomServiceAccount; + +mod config_default_credentials; +pub use config_default_credentials::ConfigDefaultCredentials; + +mod metadata_service_account; +pub use metadata_service_account::MetadataServiceAccount; + +mod gcloud_authorized_user; +pub use gcloud_authorized_user::GCloudAuthorizedUser; + +mod types; +use types::HttpClient; +pub use types::{Signer, Token}; + +/// Finds a service account provider to get authentication tokens from +/// +/// Tries the following approaches, in order: +/// +/// 1. Check if the `GOOGLE_APPLICATION_CREDENTIALS` environment variable if set; +/// if so, use a custom service account as the token source. +/// 2. Look for credentials in `.config/gcloud/application_default_credentials.json`; +/// if found, use these credentials to request refresh tokens. +/// 3. Send a HTTP request to the internal metadata server to retrieve a token; +/// if it succeeds, use the default service account as the token source. +/// 4. Check if the `gcloud` tool is available on the `PATH`; if so, use the +/// `gcloud auth print-access-token` command as the token source. +#[instrument(level = Level::DEBUG)] +pub async fn provider() -> Result, Error> { + debug!("initializing gcp_auth"); + if let Some(provider) = CustomServiceAccount::from_env()? { + return Ok(Arc::new(provider)); + } + + let client = HttpClient::new()?; + let default_user_error = match ConfigDefaultCredentials::with_client(&client).await { + Ok(provider) => { + debug!("using ConfigDefaultCredentials"); + return Ok(Arc::new(provider)); + } + Err(e) => e, + }; + + let default_service_error = match MetadataServiceAccount::with_client(&client).await { + Ok(provider) => { + debug!("using MetadataServiceAccount"); + return Ok(Arc::new(provider)); + } + Err(e) => e, + }; + + let gcloud_error = match GCloudAuthorizedUser::new().await { + Ok(provider) => { + debug!("using GCloudAuthorizedUser"); + return Ok(Arc::new(provider)); + } + Err(e) => e, + }; + + Err(Error::NoAuthMethod( + Box::new(gcloud_error), + Box::new(default_service_error), + Box::new(default_user_error), + )) +} + +/// A trait for an authentication context that can provide tokens +#[async_trait] +pub trait TokenProvider: Send + Sync { + /// Get a valid token for the given scopes + /// + /// Tokens are cached until they expire, so this method will only fetch a fresh token once + /// the current token (for the given scopes) has expired. + async fn token(&self, scopes: &[&str]) -> Result, Error>; + + /// Get the project ID for the authentication context + async fn project_id(&self) -> Result, Error>; +} + +/// Enumerates all possible errors returned by this library. +#[derive(Error, Debug)] +pub enum Error { + /// No available authentication method was discovered + /// + /// Application can authenticate against GCP using: + /// + /// - Default service account - available inside GCP platform using GCP Instance Metadata server + /// - GCloud authorized user - retrieved using `gcloud auth` command + /// + /// All authentication methods have been tested and none succeeded. + /// Service account file can be downloaded from GCP in json format. + #[error("no available authentication method found")] + NoAuthMethod(Box, Box, Box), + + /// Could not connect to server + #[error("{0}")] + Http(&'static str, #[source] hyper::Error), + + #[error("{0}: {1}")] + Io(&'static str, #[source] std::io::Error), + + #[error("{0}: {1}")] + Json(&'static str, #[source] serde_json::error::Error), + + #[error("{0}: {1}")] + Other( + &'static str, + #[source] Box, + ), + + #[error("{0}")] + Str(&'static str), +} diff --git a/engine/vendored/gcp-auth/src/metadata_service_account.rs b/engine/vendored/gcp-auth/src/metadata_service_account.rs new file mode 100644 index 0000000000..1081d3af25 --- /dev/null +++ b/engine/vendored/gcp-auth/src/metadata_service_account.rs @@ -0,0 +1,103 @@ +use std::{str, sync::Arc}; + +use async_trait::async_trait; +use bytes::Bytes; +use http_body_util::Full; +use hyper::{Method, Request}; +use tokio::sync::RwLock; +use tracing::{debug, instrument, Level}; + +use crate::{ + types::{HttpClient, Token}, + Error, TokenProvider, +}; + +/// A token provider that queries the GCP instance metadata server for access tokens +/// +/// See https://cloud.google.com/compute/docs/metadata/predefined-metadata-keys for details. +#[derive(Debug)] +pub struct MetadataServiceAccount { + client: HttpClient, + project_id: Arc, + token: RwLock>, +} + +impl MetadataServiceAccount { + /// Check that the GCP instance metadata server is available and try to fetch a token + pub async fn new() -> Result { + let client = HttpClient::new()?; + Self::with_client(&client).await + } + + pub(crate) async fn with_client(client: &HttpClient) -> Result { + debug!("try to fetch token from GCP instance metadata server"); + let token = RwLock::new(Self::fetch_token(client).await?); + + debug!("getting project ID from GCP instance metadata server"); + let req = metadata_request(DEFAULT_PROJECT_ID_GCP_URI); + let body = client.request(req, "MetadataServiceAccount").await?; + let project_id = match str::from_utf8(&body) { + Ok(s) if !s.is_empty() => Arc::from(s), + Ok(_) => { + return Err(Error::Str( + "empty project ID from GCP instance metadata server", + )) + } + Err(_) => { + return Err(Error::Str( + "received invalid UTF-8 project ID from GCP instance metadata server", + )) + } + }; + + Ok(Self { + client: client.clone(), + project_id, + token, + }) + } + + #[instrument(level = Level::DEBUG, skip(client))] + async fn fetch_token(client: &HttpClient) -> Result, Error> { + client + .token( + &|| metadata_request(DEFAULT_TOKEN_GCP_URI), + "MetadataServiceAccount", + ) + .await + } +} + +#[async_trait] +impl TokenProvider for MetadataServiceAccount { + async fn token(&self, _scopes: &[&str]) -> Result, Error> { + let token = self.token.read().await.clone(); + if !token.has_expired() { + return Ok(token); + } + + let mut locked = self.token.write().await; + let token = Self::fetch_token(&self.client).await?; + *locked = token.clone(); + Ok(token) + } + + async fn project_id(&self) -> Result, Error> { + Ok(self.project_id.clone()) + } +} + +fn metadata_request(uri: &str) -> Request> { + Request::builder() + .method(Method::GET) + .uri(uri) + .header("Metadata-Flavor", "Google") + .body(Full::from(Bytes::new())) + .unwrap() +} + +// https://cloud.google.com/compute/docs/metadata/predefined-metadata-keys +const DEFAULT_PROJECT_ID_GCP_URI: &str = + "http://metadata.google.internal/computeMetadata/v1/project/project-id"; +const DEFAULT_TOKEN_GCP_URI: &str = + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; diff --git a/engine/vendored/gcp-auth/src/types.rs b/engine/vendored/gcp-auth/src/types.rs new file mode 100644 index 0000000000..17b6153453 --- /dev/null +++ b/engine/vendored/gcp-auth/src/types.rs @@ -0,0 +1,362 @@ +use std::{env, fmt, fs::File, path::Path, str::FromStr, sync::Arc, time::Duration}; + +use bytes::Buf; +use chrono::{DateTime, Utc}; +use http_body_util::{BodyExt, Full}; +use hyper::{body::Bytes, Request}; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use rsa::{ + pkcs1v15::SigningKey, + pkcs8::DecodePrivateKey, + signature::{SignatureEncoding, Signer as _}, + RsaPrivateKey, +}; +use serde::{Deserialize, Deserializer}; +use sha2::Sha256; +use tracing::{debug, warn}; + +use crate::Error; + +#[derive(Clone, Debug)] +pub(crate) struct HttpClient { + inner: Client, Full>, +} + +impl HttpClient { + pub(crate) fn new() -> Result { + // Vendored change: native/platform TLS (hyper-tls) instead of + // hyper-rustls, so `ring` is not pulled into the dependency tree. + // Uses the OS trust store; connects over HTTP/1.1 (hyper-tls does not + // forward the negotiated-h2 ALPN hint to hyper-util). + let mut http = HttpConnector::new(); + http.enforce_http(false); + let mut tls = native_tls::TlsConnector::builder(); + tls.request_alpns(&["http/1.1"]); + let tls = tls + .build() + .map_err(|err| Error::Other("failed to build native TLS connector", Box::new(err)))?; + let mut https = + hyper_tls::HttpsConnector::from((http, tokio_native_tls::TlsConnector::from(tls))); + https.https_only(false); + + Ok(Self { + inner: Client::builder(TokioExecutor::new()).build(https), + }) + } + + pub(crate) async fn token( + &self, + request: &impl Fn() -> Request>, + provider: &'static str, + ) -> Result, Error> { + let mut retries = 0; + let body = loop { + let err = match self.request(request(), provider).await { + // Early return when the request succeeds + Ok(body) => break body, + Err(err) => err, + }; + + warn!( + ?err, + provider, retries, "failed to refresh token, trying again..." + ); + + retries += 1; + if retries >= RETRY_COUNT { + return Err(err); + } + }; + + serde_json::from_slice(&body) + .map_err(|err| Error::Json("failed to deserialize token from response", err)) + } + + pub(crate) async fn request( + &self, + req: Request>, + provider: &'static str, + ) -> Result { + debug!(url = ?req.uri(), provider, "requesting token"); + let (parts, body) = self + .inner + .request(req) + .await + .map_err(|err| Error::Other("HTTP request failed", Box::new(err)))? + .into_parts(); + + let mut body = body + .collect() + .await + .map_err(|err| Error::Http("failed to read HTTP response body", err))? + .aggregate(); + + let body = body.copy_to_bytes(body.remaining()); + if !parts.status.is_success() { + let body = String::from_utf8_lossy(body.as_ref()); + warn!(%body, status = ?parts.status, "token request failed"); + return Err(Error::Str("token request failed")); + } + + Ok(body) + } +} + +/// Represents an access token that can be used as a bearer token in HTTP requests +/// +/// Tokens should not be cached, the [`AuthenticationManager`] handles the correct caching +/// already. +/// +/// The token does not implement [`Display`] to avoid accidentally printing the token in log +/// files, likewise [`Debug`] does not expose the token value itself which is only available +/// using the [Token::`as_str`] method. +/// +/// [`AuthenticationManager`]: crate::AuthenticationManager +/// [`Display`]: fmt::Display +/// Token data as returned by the server +/// +/// https://cloud.google.com/iam/docs/reference/sts/rest/v1/TopLevel/token#response-body +#[derive(Clone, Deserialize)] +pub struct Token { + access_token: String, + #[serde( + deserialize_with = "deserialize_time", + rename(deserialize = "expires_in") + )] + expires_at: DateTime, +} + +impl Token { + pub(crate) fn from_string(access_token: String, expires_in: Duration) -> Self { + Token { + access_token, + expires_at: Utc::now() + expires_in, + } + } + + /// Define if the token has has_expired + /// + /// This takes an additional 30s margin to ensure the token can still be reasonably used + /// instead of expiring right after having checked. + /// + /// Note: + /// The official Python implementation uses 20s and states it should be no more than 30s. + /// The official Go implementation uses 10s (0s for the metadata server). + /// The docs state, the metadata server caches tokens until 5 minutes before expiry. + /// We use 20s to be on the safe side. + pub fn has_expired(&self) -> bool { + self.expires_at - Duration::from_secs(20) <= Utc::now() + } + + /// Get str representation of the token. + pub fn as_str(&self) -> &str { + &self.access_token + } + + /// Get expiry of token, if available + pub fn expires_at(&self) -> DateTime { + self.expires_at + } +} + +impl fmt::Debug for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Token") + .field("access_token", &"****") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// An RSA PKCS1 SHA256 signer. +/// +/// Vendored change: backed by the pure-Rust `rsa` crate instead of `ring`, so +/// no `ring` dependency is pulled in. Semantics are identical (RSASSA-PKCS1-v1_5 +/// over SHA-256), producing the same signatures for a given key and input. +pub struct Signer { + key: SigningKey, +} + +impl Signer { + pub(crate) fn new(pem_pkcs8: &str) -> Result { + let key = match rustls_pemfile::private_key(&mut pem_pkcs8.as_bytes()) { + Ok(Some(key)) => key, + Ok(None) => { + return Err(Error::Str( + "no private key found in credentials private key data", + )) + } + Err(err) => { + return Err(Error::Io( + "failed to read credentials private key data", + err, + )) + } + }; + + let private_key = RsaPrivateKey::from_pkcs8_der(key.secret_der()) + .map_err(|_| Error::Str("invalid private key in credentials"))?; + Ok(Signer { + key: SigningKey::::new(private_key), + }) + } + + /// Sign the input message and return the signature + pub fn sign(&self, input: &[u8]) -> Result, Error> { + // `Signer::sign` hashes `input` with SHA-256 then applies + // RSASSA-PKCS1-v1_5, matching ring's `RSA_PKCS1_SHA256`. + Ok(self.key.sign(input).to_vec()) + } +} + +impl fmt::Debug for Signer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Signer").finish() + } +} + +fn deserialize_time<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let seconds_from_now: u64 = Deserialize::deserialize(deserializer)?; + Ok(Utc::now() + Duration::from_secs(seconds_from_now)) +} + +#[derive(Deserialize)] +pub(crate) struct ServiceAccountKey { + /// project_id + pub(crate) project_id: Option>, + /// private_key + pub(crate) private_key: String, + /// client_email + pub(crate) client_email: String, + /// token_uri + pub(crate) token_uri: String, +} + +impl ServiceAccountKey { + pub(crate) fn from_env() -> Result, Error> { + env::var_os("GOOGLE_APPLICATION_CREDENTIALS") + .map(|path| { + debug!( + ?path, + "reading credentials file from GOOGLE_APPLICATION_CREDENTIALS env var" + ); + Self::from_file(&path) + }) + .transpose() + } + + pub(crate) fn from_file(path: impl AsRef) -> Result { + let file = File::open(path.as_ref()) + .map_err(|err| Error::Io("failed to open application credentials file", err))?; + serde_json::from_reader(file) + .map_err(|err| Error::Json("failed to deserialize ApplicationCredentials", err)) + } +} + +impl FromStr for ServiceAccountKey { + type Err = Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s) + .map_err(|err| Error::Json("failed to deserialize ApplicationCredentials", err)) + } +} + +impl fmt::Debug for ServiceAccountKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ApplicationCredentials") + .field("client_email", &self.client_email) + .field("project_id", &self.project_id) + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +pub(crate) struct AuthorizedUserRefreshToken { + /// Client id + pub(crate) client_id: String, + /// Client secret + pub(crate) client_secret: String, + /// Project ID + pub(crate) quota_project_id: Option>, + /// Refresh Token + pub(crate) refresh_token: String, +} + +impl AuthorizedUserRefreshToken { + pub(crate) fn from_file(path: impl AsRef) -> Result { + let file = File::open(path.as_ref()) + .map_err(|err| Error::Io("failed to open application credentials file", err))?; + serde_json::from_reader(file) + .map_err(|err| Error::Json("failed to deserialize ApplicationCredentials", err)) + } +} + +impl fmt::Debug for AuthorizedUserRefreshToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UserCredentials") + .field("client_id", &self.client_id) + .field("quota_project_id", &self.quota_project_id) + .finish_non_exhaustive() + } +} + +/// How many times to attempt to fetch a token from the set credentials token endpoint. +const RETRY_COUNT: u8 = 5; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_with_time() { + let s = r#"{"access_token":"abc123","expires_in":100}"#; + let token: Token = serde_json::from_str(s).unwrap(); + let expires = Utc::now() + Duration::from_secs(100); + + assert_eq!(token.as_str(), "abc123"); + + // Testing time is always racy, give it 1s leeway. + let expires_at = token.expires_at(); + assert!(expires_at < expires + Duration::from_secs(1)); + assert!(expires_at > expires - Duration::from_secs(1)); + } +} + +#[cfg(test)] +mod signer_tests { + use super::Signer; + + // Throwaway 2048-bit RSA test key (never used outside tests). + const TEST_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDBNGLuWaOAQTZh\nbbAxunr5KNo47W7840lu/aw74fsgORNyqfJjRiN/YMFfPi4l9ZpGeKREgfwUOT63\n/aGlIybldy6lFGdxvIhYiXXeni0mGb7gCXLHVrxZoAqKHqTw4ggClO8UsvSqdtvk\nGGSzdZErULDzI5KQaoQ3iV29F0cCE/6GIDA62Fam81UcqxnNYxr3TIQ6iL7e7qqH\nWibYKmpj+rf2hV5Osod8KJMXI5hC0uyDT/L3cg0b1E7azPiiSvz47UQ7qXEbAfP2\n397ytXvql+Hd9vcEcGUP3BDRrlFVwU+TTInM6ty7rvOZGt1yZnDwji4jHgEdT4Ko\nwnMZEjAVAgMBAAECggEAJb/bmKCRDq0vN+gbpgu+nVI7GSZjKiwqm/IapfSogYpF\nX4EPKBB7PRclkTtv/uC3DQ/jYLNZEoaA16hJ3h85KVqZFY4gDBv/M/Vfv2h+f9RF\n9DZEY+hxkr1vcb89EQfI8uAwuoWgwnHI0w9lFZ9iBumUOV149JirTsKbOygCKshw\nN3klpQ4AgFFuSH44FFACTxl2KiBoSAH1xN/nWD8KNbpQYEVqzOoy32nTOs0BS2Qb\nkel5rojR5Ei7vQBc1BXB1AuJev3NZWQtih3d7BtzziWyNTLEp7+4miVKl/Sqsmls\nwyP1Evcw4GeHpGV95+XbAHCRSgDxZkmUasJZIvIXXQKBgQDty62ls8ZcOZGzud16\nnkG+rA59lxkyFmahCZGXXs90Dj1WhLb7XXY/U2+BDYtG6zCj9jSUi8Dy6p9ZVwmN\ntuHIO0nlMUc6wc3PAoTnImoAOV+91E5r4Jn1NgMFgdLuG5lhO5ojQ9fD9yWS4lhc\nX2+70A0zZd+4ypdUg3DsJ+wfnwKBgQDP/tA9/lSpO4rNY+moz0k2aBNbeTiF9StM\nOoQpq7pbGacbdc8LeEhhowqLt6mMkj40Ce/vFX7VW2XcB6t4bON//s4vG9THoLlp\n5tUemVlMrbsK48Dv2a3Yg0OHHl8AzdX6xDM51Z6Qv7r6PEPT37p+OPZ/vGEFWDza\ntiqk9uHDywKBgDl/4apKsTFFvmSOEe7/a3hWlF5r9ey1m/VeofTPOSyf8NcF2lUn\nwVsIqtKy2rW4Uxeihg5RSMO0Vfm9YRMCYNAQ/gpMgyPDDyf6PPbCzIznUq5NMvVE\n5xVzDQH85WssA0eOqPPUCM1a6pv83U7gyNzKLxb5kEJXwoXuDpUcBi2TAoGBAK++\nf5gSINjJnbOD+3eOhi75a3m8CF1v1cDYJLnNB25YU5FpTqNDY+1TxOJfMly7aOGx\nj9E1GXEPhBaRSHo9j1CkLPUzD+wJSwFHcMYlDoYyuTsvS+Odyz2JU/KEYAOe6HG1\nfA8fB5cI2eT8LNeGT969JNKzikrozqqCh6/RhttXAoGAUCu2KL45Vm2cu4t/bQhI\nAKDcCqq9fI06bQNYEQgI8IXKzS/yhL6lAlHb/6y6YIJGS9ZCj0JEQHBU+jO1N/HT\nvoDolQmucf4tJ6zSZ8TQV+J3iuV9N/hXecsSXNcfJ0qHGEvBMhbwv/3+UbqbvU2n\ndHuQb10vSP+e3ncv6hCIuNM=\n-----END PRIVATE KEY-----\n"; + // Reference RSASSA-PKCS1-v1_5 / SHA-256 signature over TEST_MSG, produced + // by `openssl dgst -sha256 -sign`. PKCS1v15 is deterministic, so the + // vendored (rsa-crate) Signer must reproduce these exact bytes. + const TEST_MSG: &[u8] = b"baml.vertex.jwt.assertion.test"; + const EXPECTED_SIG_HEX: &str = "8607e65a2cc1c6bcf058a6a78fd80086fa64a54a4ab934f6246763cf33a80a0274539dfbfa051e228284af05d8017975f5976aac8d766b9809876b4ac4d0b4f97202745febb449ce740e67a85dee19a1c30ee8fe29e89276d6e16788137ee39622b1e7e3019716bf5d10eabc32040bd81933000f914508106cb6e7bc8bc737a893c655d4c1619370aa2b2fdefa24626b6c9e9a26f4e2e9bb3c196d73abb1d27dc659f06a91b46264eea1771e703d9b8915c5bbeed6aab529f2b238c63c7e2907ac121941c3b7e8147f214e403ba71f51df05514e8ab203170b40300bd5321bd8a6b1b55670f2bb1eb043f2af68b9e10df16ac516e18710250600790e7938bdf1"; + + fn hex_to_bytes(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + #[test] + fn rsa_signer_matches_openssl_rs256() { + let signer = Signer::new(TEST_PEM).expect("load key"); + let got = signer.sign(TEST_MSG).expect("sign"); + assert_eq!(got.len(), 256, "RS256 over a 2048-bit key is 256 bytes"); + assert_eq!( + got, + hex_to_bytes(EXPECTED_SIG_HEX), + "vendored rsa-crate signer must byte-match openssl RS256" + ); + } +}