commit 7b5aa6fcc99f16ef5019daf2376ae5575120fd0e Author: Bruno Charest Date: Fri Sep 18 18:01:07 2026 -0400 Add initial llm-bridge project diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d699ecb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +# The gates this repository relies on: formatting, lints and the 190-odd tests +# that need neither a browser nor a network. The browser end-to-end suite is a +# separate, opt-in job: it drives a real Chromium against a local fixture, and a +# runner without a browser should not make the whole pipeline red. + +on: + # A push to the default branch, and every pull request. Add the branch name + # here if this repository defaults to something else. + push: + branches: [main, master] + pull_request: + workflow_dispatch: + schedule: + # Every night at 04:17 UTC, so the browser suite still runs on its own. + - cron: "17 4 * * *" + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + check: + name: fmt, clippy and tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache the build + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all --check + + - name: Lint every target + run: cargo clippy --all-targets --locked -- -D warnings + + # Unit tests, binary tests, HTTP integration tests against the mock + # backend, and doc tests. Everything here runs without Chrome. + - name: Run the test suite + run: cargo test --locked --all-targets + + - name: Run the doc tests + run: cargo test --locked --doc + + - name: Build the release binary + run: cargo build --locked --release + + browser-e2e: + name: browser end-to-end (Chromium + fixture) + runs-on: ubuntu-latest + # On demand, or with the nightly run: these tests need a browser and are the + # ones most sensitive to the runner image. + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache the build + uses: Swatinem/rust-cache@v2 + + # The runner image ships Chrome; the tests find it through CHROME, which + # is the same override a user can set on a machine with an unusual layout. + - name: Locate a Chromium-based browser + run: | + for path in /usr/bin/google-chrome /usr/bin/google-chrome-stable /usr/bin/chromium /usr/bin/chromium-browser; do + if [ -x "$path" ]; then + "$path" --version + echo "CHROME=$path" >> "$GITHUB_ENV" + exit 0 + fi + done + echo "no Chromium-based browser found on this runner" >&2 + exit 1 + + # The suite is #[ignore]d by default so that "cargo test" stays usable on + # a machine without a browser. + - name: Run the browser suite + run: cargo test --locked --test e2e_browser -- --ignored --test-threads=1 --nocapture diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24dd03c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +*.log +.demo-state/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a39cce4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2742 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chromiumoxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ed067eb6c1f660bdb87c05efb964421d2ca262bae0296cdfe38cf0cd949a3e" +dependencies = [ + "async-tungstenite", + "base64 0.22.1", + "bytes", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "which", + "windows-registry", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a6a03a7ebac4ea85308f285d6959a3e6b2ce32a0c9465dc7a7b1db0144eec7" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c602dea92337bc4d824668d78c5b79c3b4ddb29b40dd7218282bbe8fd3fc2091" +dependencies = [ + "chromiumoxide_types", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678d5146e74f16fc4a41978b275af572cd913de1f10270d2b93b6c276bc57d80" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "llm-bridge" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "async-stream", + "async-trait", + "axum", + "base64 0.22.1", + "chromiumoxide", + "clap", + "dirs", + "futures", + "http-body-util", + "hyper", + "mime_guess", + "notify", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "tiktoken-rs", + "tokio", + "tokio-stream", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiktoken-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44075987ee2486402f0808505dd65692163d243a337fc54363d49afac41087f6" +dependencies = [ + "anyhow", + "base64 0.21.7", + "bstr", + "fancy-regex", + "lazy_static", + "parking_lot", + "regex", + "rustc-hash 1.1.0", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..890960b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "llm-bridge" +version = "0.1.0" +edition = "2021" +description = "OpenAI-compatible local API gateway that drives a real authenticated browser to talk to LLM web UIs" +license = "MIT" +default-run = "llm-gateway" +rust-version = "1.85" + +[[bin]] +name = "llm-gateway" +path = "src/main.rs" + +[lib] +name = "llm_bridge" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1", features = ["full"] } +axum = "0.8" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +hyper = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chromiumoxide = "0.9" +futures = "0.3" +async-trait = "0.1" +tokio-stream = "0.1" +async-stream = "0.3" +clap = { version = "4", features = ["derive", "env", "wrap_help"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" +anyhow = "1" +thiserror = "2" +toml = "0.8" +uuid = { version = "1", features = ["v4", "v7", "serde"] } +notify = "8" +arc-swap = "1" +dirs = "6" +base64 = "0.22" +regex = "1" +sha2 = "0.10" +mime_guess = "2" +tempfile = "3" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +tiktoken-rs = "0.6" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" + +[profile.release] +opt-level = 3 +lto = "thin" +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bbe936 --- /dev/null +++ b/README.md @@ -0,0 +1,498 @@ +# llm-bridge — llm-gateway + +A local, OpenAI-compatible HTTP API that answers by driving a **real, signed-in +browser session** on an LLM web UI instead of a paid API key. + +``` +OpenAI SDK / curl / Continue / Cursor + | + | POST /v1/chat/completions (OpenAI format) + v + llm-gateway (Axum, Rust) + | + | Chrome DevTools Protocol + v + Chrome/Chromium, dedicated profile, signed in once + | + v + chatgpt.com (or any configured chat web UI) +``` + +The client never knows a browser is involved: it sends OpenAI requests and +receives OpenAI responses, including SSE streaming. + +## What it does + +- `POST /v1/chat/completions` and `POST /v1/completions` (legacy), with `stream: true`. +- `GET /v1/models`, `GET /health`, `GET /v1/providers/{provider}/status`, + `POST /v1/providers/{provider}/validate`, `POST /v1/admin/reload`. Both + provider routes accept the `provider@account` spelling, so one account of a + provider can be checked on its own. +- Multi-turn continuity: each OpenAI conversation is mapped to a web + conversation, so only the newest message is sent once the thread exists. +- **Markdown fidelity**: once the answer has settled the gateway clicks the + provider's own copy button and reads the clipboard over CDP, so tables, code + fences and lists arrive intact instead of being flattened by `innerText`. +- **Several providers**: ChatGPT, Claude and DeepSeek ship as configuration + files; adding another one is a TOML file, not a code change. +- **Several accounts per provider**: each declared account gets its own browser + profile, session and tab pool, and is selected with `model = "chatgpt@perso"`. +- **Several completions per request**: `n > 1` is honoured, and the variants run + in parallel when the account has more than one tab (`max_tabs`). +- Optional local dashboard on `/dashboard` with live logs and manual tests. +- Every selector lives in `~/.llm-gateway/providers/.toml`, reloaded + live: adapting to a UI change is a configuration edit, not a rebuild. + +## Requirements + +- Rust 1.85 or newer (tested with 1.98). +- Chrome, Chromium, Edge or Brave. Firefox is **not** supported: it does not + speak CDP. + +## Install and first run + +```bash +cargo build --release +./target/release/llm-gateway config init # creates ~/.llm-gateway +./target/release/llm-gateway login chatgpt # sign in once, in the window that opens +./target/release/llm-gateway test chatgpt # send one prompt and print the answer +./target/release/llm-gateway serve # starts the API on 127.0.0.1:8080 +``` + +Three providers are installed on the first run: `chatgpt`, `claude` and +`deepseek`. Each needs its own sign-in: + +```bash +./target/release/llm-gateway login claude +./target/release/llm-gateway selftest claude # do the selectors still match? +``` + +The Claude and DeepSeek selectors were collected from public sources and have not +been validated against a signed-in session here: run the selftest first, and see +`providers/selectors.md` for which key to fix if the UI moved. + +Then, with any OpenAI client: + +```bash +# bash / zsh +curl -s http://127.0.0.1:8080/v1/chat/completions \ + -H 'content-type: application/json' \ + -d '{"model": "chatgpt", "messages": [{"role": "user", "content": "Bonjour"}]}' +``` + +```powershell +# PowerShell: single quotes are literal, so do NOT escape the double quotes. +# Writing '{\"model\":...}' sends the backslashes and the body is rejected with +# "Failed to parse the request body as JSON". +curl.exe -s http://127.0.0.1:8080/v1/chat/completions -H "content-type: application/json" ` + -d '{"model":"chatgpt","messages":[{"role":"user","content":"Bonjour"}]}' + +# Or, without any quoting puzzle at all: +$body = @{ model = "chatgpt"; messages = @(@{ role = "user"; content = "Bonjour" }) } | + ConvertTo-Json -Depth 5 +Invoke-RestMethod -Uri http://127.0.0.1:8080/v1/chat/completions -Method Post ` + -Body $body -ContentType application/json + +# ./demo.ps1 walks through the whole API (health, models, streaming, status, +# validation, reload) so you do not have to type any of this by hand. +``` + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="unused") +print(client.chat.completions.create( + model="chatgpt", + messages=[{"role": "user", "content": "Bonjour"}], +).choices[0].message.content) +``` + +Streaming works the same way with `stream=True`. + +## Command line + +``` +llm-gateway [GLOBAL OPTIONS] [COMMAND] + +serve [--backend browser|mock] start the API server (default command) +test |--all [--prompt TEXT] [--json] +selftest |--all [--json] +list [--json] +login [--account ID] open a window to sign in once +logout [--account ID] [--all] delete the stored profile(s) +conversations list|clear [--provider NAME[@ACCOUNT]] [--account ID] +reload ask a running server to reload its configuration +config path|init|show +``` + +Global options: `--host`, `--port`, `--browser `, +`--browser-path`, `--profile-dir`, `--provider`, `--headless`, `--log-level`, +`--log-format pretty|json`, `--config`, `--debug`, `--max-tabs`, `--api-key`, +`--markdown auto|clipboard|dom|text`, `--yes`. + +Exit codes: `0` success, `1` runtime error, `2` selftest failure. + +## Model names + +`model` selects the provider: + +| Value | Meaning | +|---|---| +| `chatgpt` | the provider, its default model | +| `chatgpt/gpt-4o` | the provider and a model name recorded in the response | +| `gpt-4o` | accepted when exactly one provider is configured | + +The model is **echoed back** but the web UI keeps whatever model it has +selected; a warning is added to `x_gateway_warnings` when you ask for another +one. Driving the UI model picker is not implemented yet. + +When a provider declares several accounts, the account is part of the same +string, between the provider and the model name: + +| Value | Meaning | +|---|---| +| `chatgpt` | the provider, its default account | +| `chatgpt@perso` | the account whose id (or label) is `perso` | +| `chatgpt@bruno.charest@gmail.com` | the account with that address | +| `chatgpt@perso/gpt-4o` | that account, and a model name | + +`GET /v1/models` lists one entry per provider and per declared account, so a +client that only reads that endpoint still discovers them. + +The same spelling works outside `model`, wherever a provider is named: + +| Command | Effect | +|---|---| +| `test chatgpt@perso` / `selftest chatgpt@perso` | checks that one account | +| `test --all` / `selftest --all` | checks **every account** of every provider | +| `GET /v1/providers/chatgpt@perso/status` | the state of that account | +| `POST /v1/providers/chatgpt@pro/validate` | a full report for that account | + +A bare provider name means its default account, and the answer says which one was +actually probed (`"provider": "chatgpt@perso"`), so two accounts of the same +provider are never confused in a report or in the logs. + +## Multi-turn conversations + +A client that resends the whole history (every OpenAI SDK does) is mapped to one +web conversation: + +1. The conversation id comes from the `X-Conversation-Id` request header when + present, otherwise from a fingerprint of the system prompt and the first user + message (`conversation.id_source = "fingerprint"` in the config). +2. The first turn replays the whole history into the page. +3. Later turns reopen the stored web conversation and send **only the newest + message**. +4. The response always carries `X-Conversation-Id`, so a client can pin a + thread explicitly. +5. If the web conversation is gone, the turn automatically falls back to + replaying the history in a new one. + +`~/.llm-gateway/conversations.json` is plain JSON you can read and edit: + +```json +{ + "version": 1, + "threads": { + "fp-3f1a...": { + "provider": "chatgpt", + "account": "perso", + "web_url": "https://chatgpt.com/c/68f0...", + "web_id": "68f0...", + "created_at": 1760000000, + "last_used_at": 1760000100, + "turns": 3, + "messages_sent": 7, + "state": "ready" + } + } +} +``` + +`llm-gateway conversations list` and `clear` inspect and prune it. The listing +shows the account that served each thread, and both commands accept +`--provider chatgpt@perso` or `--account perso` to narrow the selection; +`--account default` selects the threads of a provider that declares no account. +A thread is marked `incomplete` when a client disconnects mid-generation; the +next turn then replays the history instead of trusting a half-written thread. + +## OpenAI parameter support + +| Parameter | Behaviour | +|---|---| +| `model` | selects the provider (see above) | +| `messages` | string content or an array of `text` / `image_url` parts | +| `stream` | SSE chunks, `data: [DONE]` termination | +| `stream_options.include_usage` | adds a final usage chunk | +| `temperature`, `top_p`, `max_tokens`, `stop`, `response_format`, `seed`, `user`, penalties, `logprobs` | ignored, logged, and listed in `x_gateway_warnings` | +| `tools`, `functions`, `tool_choice` | **not supported**: HTTP 400 `unsupported_parameter` | +| `n > 1` | supported: `n` completions, each in its own web conversation. Clamped to `capture.max_variants` (4 by default) with a warning. Variants run in parallel when the account allows more than one tab | + +`usage` is estimated with `tiktoken` when its vocabulary can be loaded, and with +a characters/4 heuristic otherwise (offline machines). + +`/v1/completions` accepts a string or an array prompt and answers with the +legacy `text_completion` shape. Streaming is not available on that endpoint. + +## Signing in with Google (or any OAuth provider that blocks automation) + +Symptom: after `llm-gateway login chatgpt`, the Google window answers +"**This browser or app may not be secure**". + +Why it happens: Google refuses to sign a browser it identifies as automated or +outdated. Two things mattered here, and the first one was a bug in this project: + +1. The stealth helper used to advertise an **outdated Chrome user agent** + (`Chrome/107` in 2026). That alone reads as "old browser = insecure", which is + exactly what the message means. It is fixed: the default is now + `[browser] stealth = "off"` and the user agent is never spoofed. +2. Google can also refuse a browser it knows is being driven over CDP. That part + cannot be argued with, so the gateway offers a sign-in path where no + automation is involved at all. + +### Recommended: sign in from a browser you start yourself + +```bash +llm-gateway login chatgpt --manual # prints the exact command, or add --launch +``` + +It tells you to run something like: + +``` +"C:\Program Files\Google\Chrome\Application\chrome.exe" \ + --user-data-dir="C:\Users\you\.llm-gateway\profiles\chatgpt\user-data-dir" \ + --no-first-run --no-default-browser-check https://chatgpt.com/ +``` + +Sign in with Google in that window: it is an ordinary Chrome launched by you, +with no automation switch and no debugging port. Then close it and press Enter. +The gateway relaunches that same profile for later requests, so the session is +already there. + +### Alternative: keep that window and let the gateway drive it + +Start the same command with `--remote-debugging-port=9222` (the `--manual` output +prints it) and keep it open, then: + +```bash +llm-gateway serve --attach --debug-port 9222 +``` + +The gateway attaches to your browser, reuses a tab it can see (otherwise it opens +one in that same browser, with your session), and **never closes your browser** +when it shuts down. If Google still refuses the sign-in with the debugging port +open, use the recommended path above. + +### Also worth knowing + +- Email + password sign-in on the provider site is not blocked by any of this; + only Google's OAuth screen applies these checks. +- `--stealth minimal` adds a `navigator.webdriver` patch on top of the default; + `--stealth aggressive` restores the old user-agent spoofing and is documented + as harmful for sign-in. +- When a request hits a signed-out page, the API answers with + `401 requires_login` and the message now mentions the `--manual` path. + +## Configuration + +`~/.llm-gateway/config.toml` (created on first run, see +`config.toml.example` for the annotated version). Precedence: +**CLI flags > `LLM_GATEWAY_*` environment variables > the file > defaults**. + +The settings that matter most: + +```toml +[browser] +executable = "" # empty: auto-detect Chrome, Edge or Brave +headless = false # keep it false so you can sign in and solve captchas +max_tabs = 1 # concurrent turns per provider *account*, i.e. tabs kept open +busy_wait_s = 30 # how long a request waits for a free tab before a 429 + +[conversation] +strategy = "auto" # auto | reuse | replay +id_source = "fingerprint" # fingerprint | header | uuid + +[capture] +poll_interval_ms = 400 # DOM polling while waiting for the answer +quiet_ms = 1500 # silence after which the answer is considered final +response_timeout_s = 180 # hard budget for one answer +markdown = "auto" # auto | clipboard | dom | text (see below) +max_variants = 4 # upper bound accepted for the "n" parameter +``` + +`max_tabs` is the setting that turns the gateway from a queue into a pool: with +`max_tabs = 2` an account can answer two requests (or two variants of one `n = 2` +request) at the same time, in two tabs of the same browser. + +`capture.markdown` decides where the answer text comes from: + +| Value | Behaviour | +|---|---| +| `auto` | the provider's copy button (Markdown through the clipboard) when it has one, the DOM conversion otherwise | +| `clipboard` | always the copy button; falls back to the DOM when the clipboard cannot be read | +| `dom` | always convert the answer HTML to Markdown | +| `text` | the visible text only, formatting flattened (the historical behaviour) | + +A provider may override it in its own `[input]` section. Streamed deltas always +stay plain text: only the settled answer is re-read with full fidelity. + +Provider files (`~/.llm-gateway/providers/*.toml`) hold the URLs, the CSS +selectors, the timeouts and the capabilities of each web UI. They are watched: +editing one reloads it within a second, without restarting the server or the +browser. See `providers/selectors.md`. + +They are **copies** created on the first run, so upgrading the binary does not +change them. Refresh them after an upgrade with +`llm-gateway config init --force` (the previous file is kept next to it as +`.toml.bak`). + +`llm-gateway --version` prints the compilation date +(`0.1.0 (built 2026-09-18T17:26:11Z)`): if it does not match your sources, run +`cargo build --release` again. + +## Error mapping + +Errors use the OpenAI error shape and stable codes: + +| Situation | HTTP | code | +|---|---|---| +| Unknown model | 404 | `model_not_found` | +| Unknown account in the model string | 404 | `account_not_found` (the message lists the known ones) | +| Bad request, ignored-but-unsupported parameter | 400 | `invalid_request`, `unsupported_parameter` | +| Images sent to a provider without upload support | 400 | `unsupported_content_type` | +| Missing or wrong API key | 401 | `invalid_api_key` | +| Signed out | 401 | `requires_login` | +| Captcha challenge | 403 | `captcha_required` | +| Browser profile locked by another instance | 409 | `browser_profile_locked` | +| Tab busy (30 s wait) or upstream throttling | 429 | `provider_busy`, `upstream_rate_limit` | +| Selector gone, upstream error | 500 | `selector_missing`, `upstream_error` | +| Browser missing, provider misconfigured | 503 | `browser_unavailable`, `provider_misconfigured` | +| No answer in time | 504 | `upstream_timeout` | + +On a failure the server saves a screenshot and the full DOM in +`~/.llm-gateway/debug/`, plus one line per failure in `debug/index.jsonl` +(`--debug` does it for every request). + +## Dashboard + +`http://127.0.0.1:8080/dashboard` shows the providers, their live state, the +recent logs (SSE) and lets you send a prompt by hand. If an API key is +configured, paste it in the header field. + +## Development + +```bash +cargo fmt --all --check +cargo clippy --all-targets -- -D warnings +cargo test # no browser, no network +./scripts/e2e.ps1 # real Chrome, local fixture page + +./scripts/ci.ps1 # all of the above, in one go +./scripts/ci.ps1 -E2e # ... plus the browser suite +``` + +`.github/workflows/ci.yml` runs the same gates on Linux and Windows for every +push and pull request, and builds the release binary. The browser suite needs a +real Chromium, so it is a separate job that runs on demand (or nightly) instead +of blocking a machine that has no browser. + +- `tests/api_integration.rs` drives the whole HTTP surface through a mock backend, + including multi-account routing and `n > 1`. +- `tests/e2e_browser.rs` drives a real Chrome against + `tests/fixtures/chatgpt_mock.html`, a static fake of the ChatGPT DOM with + login, captcha, rate-limit, error, timeout, rewrite and attachment modes. That + fixture is what makes the capture loop regression-testable without a network + or an account. +- `demo.ps1` / `demo.sh` run a full cycle; add `-Mock` (or `--mock`) to run + without a browser or a login. + +## Limits and honest caveats + +- **Streamed deltas are best effort.** The final answer written into a + non-streaming response is always the exact captured text. If the web UI + rewrites text it has already shown (React re-render, code block highlighting), + a streaming client may have received text that the final answer no longer + contains. A warning is logged when that happens. +- **Markdown fidelity depends on the provider.** The clipboard path is exact, + because the provider copies what it rendered; the DOM conversion is a faithful + approximation that covers the constructs a chat UI actually produces. A page + that refuses the clipboard read (or a provider with no copy button) silently + falls back to it. Only the settled answer is re-read: streamed deltas stay + plain text. +- **One turn at a time per provider account** by default: a second request waits + up to `browser.busy_wait_s` (30 s) and then gets a 429. Raise `max_tabs` to + let an account answer several requests at once, in several tabs. +- **Only one tab per browser is rendered** by Chrome. On the other tabs the + prompt is submitted with in-page events rather than a synthetic mouse click, + which every chat UI tested here accepts, and the clipboard read still works + because the permission is granted over CDP. +- **Selectors are the fragile part.** The fixture page covers regressions in our + capture loop; it cannot predict a change on the real site. Run + `llm-gateway selftest` after a UI update. The Claude and DeepSeek files are + starting points collected from public sources, not validated against a + signed-in session. +- **No tool calling, no model switching.** +- Latency is browser latency: a fresh conversation takes a few seconds to start + generating, plus the page load. + +## Security and terms of use + +- The API binds to `127.0.0.1` by default. If you expose it on a network, + configure `server.api_key` (or `--api-key`) and put it behind TLS. +- Browser profiles contain live session cookies: they live in + `~/.llm-gateway/profiles/` and are never encrypted by this tool. + Use disk encryption and `llm-gateway logout --all` when you are done. +- The tool only uses **your own** signed-in subscriptions. It does not bypass + paywalls, quotas or captchas: when a challenge appears it stops and tells you. +- You remain responsible for complying with each provider's terms of service. + Automating a web UI may be restricted by them; check before you rely on it. + +## Layout of the state directory + +``` +~/.llm-gateway/ + config.toml global configuration + providers/ one TOML per provider (selectors, URLs, timeouts) + profiles//user-data-dir/ persistent Chrome profile + profiles//accounts//user-data-dir/ one per declared account + conversations.json client conversation -> web conversation mapping + debug/ screenshots, DOM dumps, index.jsonl + logs/llm-gateway.log JSON logs +``` + +## Providers and accounts + +| Provider | File | Models | Notes | +|---|---|---|---| +| OpenAI ChatGPT | `providers/chatgpt.toml` | `gpt-4o`, `gpt-4o-mini`, `gpt-4.1` | `#prompt-textarea`, per-answer copy button | +| Anthropic Claude | `providers/claude.toml` | `claude-sonnet-4`, `claude-opus-4`, `claude-haiku-4` | ProseMirror editor; selectors from public sources | +| DeepSeek | `providers/deepseek.toml` | `deepseek-chat`, `deepseek-reasoner` | `textarea#chat-input`, `.ds-markdown` answers | + +An account is a browser profile: declaring two ChatGPT accounts gives two +independent sessions, each with its own tab pool, driven side by side. + +```toml +[[accounts]] +id = "perso" +email = "bruno.charest@gmail.com" +default = true + +[[accounts]] +id = "pro" +email = "brunocharest.bc@gmail.com" +``` + +```bash +llm-gateway login chatgpt --account perso +llm-gateway login chatgpt@pro +llm-gateway list # shows every account and its profile +llm-gateway logout chatgpt@pro # forget one account +``` + +The model string then carries the account: +`{"model": "chatgpt@pro"}`, or `{"model": "chatgpt@brunocharest.bc@gmail.com"}`, +or `{"model": "chatgpt@pro/gpt-4o"}`. Each account keeps its own thread store +entries, so the same prompt sent to two accounts never shares one web +conversation. Adding accounts to a provider that was already signed in changes +its profile layout (`profiles//accounts//…`), so sign that +provider in again once. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..30c5ae1 --- /dev/null +++ b/build.rs @@ -0,0 +1,44 @@ +//! Build stamp: lets `llm-gateway --version` tell you when the binary was built. +//! +//! Without it, a stale release binary looks exactly like a fresh one, which has +//! already caused a confusing "unknown argument" report. + +use std::time::{SystemTime, UNIX_EPOCH}; + +fn main() { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0); + println!( + "cargo:rustc-env=LLM_GATEWAY_BUILD_TIME={}", + format_utc(seconds) + ); +} + +/// `YYYY-MM-DDTHH:MM:SSZ`, without pulling a date crate into the build script. +fn format_utc(seconds: u64) -> String { + let days = (seconds / 86_400) as i64; + let rem = seconds % 86_400; + + // Howard Hinnant's civil-from-days algorithm. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + if month <= 2 { + year += 1; + } + + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + (rem % 3600) / 60, + rem % 60 + ) +} diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..f1df0a6 --- /dev/null +++ b/config.toml.example @@ -0,0 +1,109 @@ +# llm-gateway global configuration. +# Copied to ~/.llm-gateway/config.toml on first run; edits there win. +# Precedence: CLI flags > LLM_GATEWAY_* environment variables > this file > defaults. + +[server] +# Bind address. Keep it on the loopback interface unless you set an api_key. +host = "127.0.0.1" +port = 8080 +# Optional bearer token required from clients. Empty disables authentication. +api_key = "" +# Adds an "x_gateway_warnings" array to responses describing ignored parameters. +include_warnings = true +# Maximum accepted request body, in bytes. +request_body_limit = 33554432 + +[browser] +# Path to the Chrome/Chromium/Edge/Brave executable. Empty = auto-detect +# (honours the CHROME environment variable, then well-known install paths). +executable = "" +# Headless hides the window; keep it false so you can log in and solve captchas. +headless = false +# Root of the persistent browser profiles. Empty = /profiles. +profile_dir = "" +# Concurrent turns allowed per provider account, i.e. how many tabs stay open. +# 1 serialises the turns of an account; a larger value lets several requests +# (or the variants of one "n": 4 request) run in parallel. +max_tabs = 1 +# How long a request waits for a free tab before it is refused with 429. +busy_wait_s = 30 +launch_timeout_s = 60 +request_timeout_s = 60 +# Extra Chromium command line switches. +extra_args = [ + "--disable-blink-features=AutomationControlled", + "--no-first-run", + "--no-default-browser-check", +] + +# How much the browser hides that it is automated: +# off leave the page alone (default). The switch above already hides +# navigator.webdriver and the user agent stays genuine, which is +# what sign-in pages expect. +# minimal additionally patch navigator.webdriver from an injected script. +# aggressive full stealth, including a spoofed user agent. Warning: it +# advertises an outdated Chrome version, which some sign-in +# providers reject as an insecure browser. +stealth = "off" + +# Attach to a browser you started instead of launching one. Use it when a +# sign-in provider refuses a browser the gateway launched: start Chrome +# yourself with a debugging port (llm-gateway login --manual prints +# the exact command), keep it open, and set attach = true. +attach = false +debug_port = 9222 + +[conversation] +# Thread store file. Empty = /conversations.json. +store = "" +# auto = reuse the web conversation when possible, otherwise replay the history +# reuse = always reuse (fails if the web conversation is gone) +# replay = always send the full history into a new conversation +strategy = "auto" +# fingerprint = derive an id from the opening messages (works with header-less clients) +# header = only trust the X-Conversation-Id header (else a fresh uuid per request) +# uuid = never reuse a conversation unless the header is supplied +id_source = "fingerprint" + +[capture] +# DOM polling interval while waiting for the answer. +poll_interval_ms = 400 +# The answer is considered finished after this much silence and no streaming indicator. +quiet_ms = 1500 +# Hard budget for one generated answer. +response_timeout_s = 180 +# auto | insert_text | exec_command | type_str +inject_method = "auto" +# Fidelity of the captured answer: +# auto the provider's copy button (Markdown through the clipboard) when +# it has one, the DOM conversion otherwise. Default. +# clipboard always the copy button; falls back to the DOM when the clipboard +# cannot be read. +# dom always convert the answer HTML to Markdown. +# text the visible text only, formatting flattened (historical behaviour). +markdown = "auto" +# How long the clipboard read is given before the DOM conversion wins. +clipboard_timeout_ms = 1500 +# Upper bound accepted for the OpenAI "n" parameter. Values above it are clamped +# and reported in x_gateway_warnings. +max_variants = 4 + +[tokens] +# tiktoken encoding used for the estimated usage block. +encoding = "o200k_base" +# Fall back to a characters/4 heuristic when the encoding cannot be loaded. +fallback_heuristic = true + +[debug] +# never | on_error | always +screenshots = "on_error" +# Empty = /debug. +dir = "" + +[logging] +# trace | debug | info | warn | error +level = "info" +# pretty | json +format = "pretty" +# Additional JSON log file. Empty = /logs/llm-gateway.log +file = "" diff --git a/demo.ps1 b/demo.ps1 new file mode 100644 index 0000000..fe54b23 --- /dev/null +++ b/demo.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + End to end demonstration of llm-gateway. + +.DESCRIPTION + Starts the API server on a spare port, runs a full cycle against it + (health, models, non-streaming completion, streaming completion, provider + status, validation, configuration reload) and stops the server. + + Without -Mock the server talks to a real browser: log in first with + "llm-gateway login chatgpt" and make sure the provider is configured. + With -Mock everything runs in memory, no browser and no login needed. + +.EXAMPLE + ./demo.ps1 -Mock + ./demo.ps1 -Port 8099 +#> +param( + [switch]$Mock, + [int]$Port = 8099, + [string]$Provider = 'chatgpt' +) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path +Push-Location $root + +$stateDir = Join-Path $root '.demo-state' +New-Item -ItemType Directory -Force -Path $stateDir | Out-Null +$env:LLM_GATEWAY_HOME = $stateDir + +$backend = if ($Mock) { 'mock' } else { 'browser' } +$base = "http://127.0.0.1:$Port" + +Write-Host "== building ==" -ForegroundColor Cyan +cargo build --quiet +if ($LASTEXITCODE -ne 0) { throw 'cargo build failed' } +$exe = Join-Path $root 'target/debug/llm-gateway.exe' + +Write-Host "== starting llm-gateway (backend: $backend, port: $Port) ==" -ForegroundColor Cyan +$server = Start-Process -FilePath $exe -ArgumentList @('serve', '--port', "$Port", '--backend', $backend) -PassThru -NoNewWindow + +try { + $health = $null + foreach ($attempt in 1..60) { + try { + $health = Invoke-RestMethod -Uri "$base/health" -TimeoutSec 2 + break + } catch { + Start-Sleep -Milliseconds 500 + } + } + if (-not $health) { throw "the server did not become healthy on $base" } + + Write-Host " +== /health ==" -ForegroundColor Green + $health | ConvertTo-Json -Depth 6 + + Write-Host " +== /v1/models ==" -ForegroundColor Green + (Invoke-RestMethod -Uri "$base/v1/models").data | ForEach-Object { " " + $_.id } + + $payload = @{ + model = $Provider + messages = @(@{ role = 'user'; content = 'Say ok and nothing else.' }) + } | ConvertTo-Json -Depth 5 + + Write-Host " +== POST /v1/chat/completions (non-streaming) ==" -ForegroundColor Green + $answer = Invoke-RestMethod -Uri "$base/v1/chat/completions" -Method Post -Body $payload -ContentType 'application/json' + " id : " + $answer.id + " model : " + $answer.model + " answer : " + $answer.choices[0].message.content + " usage : prompt " + $answer.usage.prompt_tokens + ", completion " + $answer.usage.completion_tokens + + Write-Host " +== POST /v1/chat/completions (two completions) ==" -ForegroundColor Green + $variantsPayload = @{ + model = $Provider + n = 2 + messages = @(@{ role = 'user'; content = 'Answer with one word.' }) + } | ConvertTo-Json -Depth 5 + $variants = Invoke-RestMethod -Uri "$base/v1/chat/completions" -Method Post -Body $variantsPayload -ContentType 'application/json' + " choices : " + $variants.choices.Count + $variants.choices | ForEach-Object { " [" + $_.index + "] " + $_.message.content } + if ($variants.x_gateway_warnings) { " warning : " + $variants.x_gateway_warnings[0] } + + $accounts = ($health.providers | Where-Object { $_.name -eq $Provider }).accounts + if ($accounts) { + Write-Host " +== accounts of $Provider ==" -ForegroundColor Green + $accounts | ForEach-Object { + " " + $_.id + " " + $_.email + $(if ($_.default) { " (default)" } else { "" }) + } + $account = $accounts[0].id + $accountPayload = @{ + model = "$Provider@$account" + messages = @(@{ role = 'user'; content = 'Say ok and nothing else.' }) + } | ConvertTo-Json -Depth 5 + $accounted = Invoke-RestMethod -Uri "$base/v1/chat/completions" -Method Post -Body $accountPayload -ContentType 'application/json' + " model $Provider@$account answered: " + $accounted.choices[0].message.content + } + + Write-Host " +== POST /v1/chat/completions (streaming) ==" -ForegroundColor Green + $streamPayload = @{ + model = $Provider + stream = $true + messages = @(@{ role = 'user'; content = 'Count from one to five.' }) + } | ConvertTo-Json -Depth 5 + $streamed = Invoke-WebRequest -Uri "$base/v1/chat/completions" -Method Post -Body $streamPayload -ContentType 'application/json' + $streamed.Content -split "`n" | Select-Object -First 12 | ForEach-Object { " " + $_ } + " ... ($((($streamed.Content -split "`n") | Measure-Object).Count) lines of SSE)" + + Write-Host " +== GET /v1/providers/$Provider/status ==" -ForegroundColor Green + Invoke-RestMethod -Uri "$base/v1/providers/$Provider/status" | ConvertTo-Json -Depth 5 + + Write-Host " +== POST /v1/providers/$Provider/validate ==" -ForegroundColor Green + Invoke-RestMethod -Uri "$base/v1/providers/$Provider/validate" -Method Post | ConvertTo-Json -Depth 5 + + Write-Host " +== POST /v1/admin/reload ==" -ForegroundColor Green + Invoke-RestMethod -Uri "$base/v1/admin/reload" -Method Post | ConvertTo-Json -Depth 5 + + Write-Host " +== dashboard ==" -ForegroundColor Green + " $base/dashboard" + " state directory: $stateDir" +} +finally { + Write-Host " +== stopping the server ==" -ForegroundColor Cyan + if ($server -and -not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit(5000) | Out-Null + } + Pop-Location +} diff --git a/demo.sh b/demo.sh new file mode 100644 index 0000000..a869099 --- /dev/null +++ b/demo.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# End to end demonstration of llm-gateway (bash / curl equivalent of demo.ps1). +# +# ./demo.sh --mock +# ./demo.sh --port 8099 --provider chatgpt +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +PORT=8099 +PROVIDER=chatgpt +MOCK=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --mock) MOCK=1; shift ;; + --port) PORT="$2"; shift 2 ;; + --provider) PROVIDER="$2"; shift 2 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac +done + +BACKEND="browser" +[[ "$MOCK" == "1" ]] && BACKEND="mock" +BASE="http://127.0.0.1:$PORT" +STATE="$ROOT/.demo-state" +mkdir -p "$STATE" +# shellcheck disable=SC2030 +export LLM_GATEWAY_HOME="$STATE" + +echo "== building ==" +cargo build --quiet +EXE="$ROOT/target/debug/llm-gateway" + +echo "== starting llm-gateway (backend: $BACKEND, port: $PORT) ==" +"$EXE" serve --port "$PORT" --backend "$BACKEND" & +SERVER_PID=$! +trap 'echo; echo "== stopping the server =="; kill "$SERVER_PID" 2>/dev/null || true' EXIT + +for _ in $(seq 1 60); do + if curl -sf "$BASE/health" >/dev/null 2>&1; then break; fi + sleep 0.5 +done + +echo +echo "== /health ==" +curl -s "$BASE/health" | python3 -m json.tool 2>/dev/null || curl -s "$BASE/health" + +echo +echo "== /v1/models ==" +curl -s "$BASE/v1/models" + +echo +echo "== POST /v1/chat/completions (non-streaming) ==" +curl -s "$BASE/v1/chat/completions" \ + -H 'content-type: application/json' \ + -d "{\"model\":\"$PROVIDER\",\"messages\":[{\"role\":\"user\",\"content\":\"Say ok and nothing else.\"}]}" + +echo +echo "== POST /v1/chat/completions (two completions) ==" +curl -s "$BASE/v1/chat/completions" \ + -H 'content-type: application/json' \ + -d "{\"model\":\"$PROVIDER\",\"n\":2,\"messages\":[{\"role\":\"user\",\"content\":\"Answer with one word.\"}]}" + +echo +echo "== accounts of $PROVIDER (when the provider file declares some) ==" +curl -s "$BASE/health" | grep -o '"accounts":\[[^]]*\]' || echo " none declared" + +echo +echo "== POST /v1/chat/completions (streaming, first lines) ==" +curl -sN "$BASE/v1/chat/completions" \ + -H 'content-type: application/json' \ + -d "{\"model\":\"$PROVIDER\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"Count from one to five.\"}]}" | head -n 12 || true + +echo +echo "== GET /v1/providers/$PROVIDER/status ==" +curl -s "$BASE/v1/providers/$PROVIDER/status" + +echo +echo "== POST /v1/providers/$PROVIDER/validate ==" +curl -s -X POST "$BASE/v1/providers/$PROVIDER/validate" + +echo +echo "== POST /v1/admin/reload ==" +curl -s -X POST "$BASE/v1/admin/reload" + +echo +echo "== dashboard ==" +echo " $BASE/dashboard" +echo " state directory: $STATE" diff --git a/providers/chatgpt.toml b/providers/chatgpt.toml new file mode 100644 index 0000000..f3a55d3 --- /dev/null +++ b/providers/chatgpt.toml @@ -0,0 +1,86 @@ +# ChatGPT provider configuration, driven entirely by this file. +# Selectors change when the web UI is updated: edit them here (reloaded live), +# never in the Rust source. Validate with: llm-gateway selftest chatgpt + +[provider] +name = "chatgpt" +display_name = "OpenAI ChatGPT" +web_url = "https://chatgpt.com/" +new_conversation_url = "https://chatgpt.com/" +# Captures the web conversation id from the current URL (one capture group). +conversation_url_pattern = "^https://chatgpt\\.com/c/([0-9a-zA-Z-]{8,})" +default_models = ["gpt-4o", "gpt-4o-mini", "gpt-4.1"] +enabled = true + +[selectors] +input_field = "div#prompt-textarea, div.ProseMirror[contenteditable='true']" +# The id and the data-testid are language independent; the aria-label fallbacks +# only match an English UI (see providers/selectors.md, "Localised user +# interfaces"). +send_button = "button[data-testid='send-button'], button#composer-submit-button, button[aria-label='Send prompt'], button[aria-label='Envoyer la requête']" +response_container = "div[data-message-author-role='assistant']" +streaming_indicator = "button[data-testid='stop-button']" +file_input = "input[type='file']" +new_chat_button = "a[data-testid='create-new-chat-button']" +# Clicked once the answer has settled, to read the provider's own Markdown +# through the clipboard. Looked up inside the answer node, so it must be +# relative to it. Leave empty to always convert the DOM instead. +copy_button = "button[data-testid='copy-turn-action-button'], button[aria-label='Copy'], button[aria-label='Copier']" +login_page_indicator = "button[data-testid='login'], form[action*='login'], a[href*='auth/login'], a[href*='auth/0']" +captcha_indicator = "iframe[src*='captcha'], iframe[src*='challenges.cloudflare.com'], div#challenge-form" +error_banner = "[data-testid='error-message'], div[role='alert']" + +[login] +url_patterns = ["/auth/login", "/login", "accounts.google.com", "/auth/0"] +# The page a signed-out visitor lands on (title "ChatGPT: Chat, Work, Create & +# Code with AI") has no prompt field and shows these call to action buttons. +# The text patterns are only used when that field is missing, so a signed-in +# conversation is never mistaken for a sign-in screen. +text_patterns = ["log in", "sign up", "create an account"] + +[rate_limit] +text_patterns = [ + "too many requests", + "rate limit", + "trop de requêtes", + "réessayez plus tard", + "you've reached", +] + +[timeouts] +page_load = 30 +response_generation = 120 +response_timeout = 180 + +[input] +poll_interval_ms = 400 +quiet_ms = 1500 +inject_method = "auto" + +[options] +supports_images = true +supports_streaming = true +supports_new_chat = true + +# Multi-account. A ChatGPT account is a browser profile, so declaring two +# accounts gives two independent sessions, each with its own tab pool, driven +# side by side. Address them with the model string: +# +# {"model": "chatgpt"} -> the default account +# {"model": "chatgpt@perso"} -> by account id +# {"model": "chatgpt@brunocharest.bc@gmail.com"} -> by address +# {"model": "chatgpt@perso/gpt-4o"} -> account and model +# +# Sign each one in once: llm-gateway login chatgpt --account perso +# Their profiles live in profiles/chatgpt/accounts//user-data-dir. +# +# [[accounts]] +# id = "perso" +# email = "bruno.charest@gmail.com" +# label = "Personal" +# default = true +# +# [[accounts]] +# id = "pro" +# email = "brunocharest.bc@gmail.com" +# label = "Work" diff --git a/providers/claude.toml b/providers/claude.toml new file mode 100644 index 0000000..2e9cea1 --- /dev/null +++ b/providers/claude.toml @@ -0,0 +1,79 @@ +# Claude (claude.ai) provider configuration, driven entirely by this file. +# Selectors change when the web UI is updated: edit them here (reloaded live), +# never in the Rust source. Validate with: llm-gateway selftest claude +# +# The values below come from the public claude.ai DOM observed in 2026 and from +# published selector lists (May 2026). They are a starting point, not a +# guarantee: run 'llm-gateway selftest claude' before relying on them. + +[provider] +name = "claude" +display_name = "Anthropic Claude" +web_url = "https://claude.ai/new" +new_conversation_url = "https://claude.ai/new" +# Captures the web conversation id from the current URL (one capture group). +conversation_url_pattern = '^https://claude\.ai/chat/([0-9a-zA-Z-]{8,})' +default_models = ["claude-sonnet-4", "claude-opus-4", "claude-haiku-4"] +enabled = true + +[selectors] +# Claude uses ProseMirror, so the editor is a contenteditable div rather than a +# textarea. The fallbacks cover the composer variants seen behind A/B tests. +input_field = "div.ProseMirror[contenteditable='true'], div[contenteditable='true'][data-placeholder], [data-testid='composer-input']" +send_button = "button[aria-label='Send Message'], button[aria-label='Send message'], button[data-testid='send-button'], button[type='submit']" +# One node per assistant answer, in document order. +response_container = "div.font-claude-response, div[data-is-streaming], div.font-claude-message" +streaming_indicator = "button[aria-label='Stop response'], button[aria-label='Stop Response']" +file_input = "input[type='file']" +new_chat_button = "a[href='/new'], button[aria-label='New chat']" +login_page_indicator = "a[href*='/login'], button[data-testid='login-button'], form[action*='login']" +captcha_indicator = "iframe[src*='challenges.cloudflare.com'], iframe[title*='challenge'], div#challenge-form" +error_banner = "[role='alert'], div[data-testid='error']" +# Relative to the answer node: the hover toolbar button that copies the answer +# as Markdown. +copy_button = "button[aria-label='Copy'], button[data-testid='copy-button']" + +[login] +url_patterns = ["/login", "/magic-link", "accounts.google.com"] +# Only consulted when the prompt field is missing, so a chat page that happens +# to mention "log in" is never misclassified. +text_patterns = ["sign in", "log in", "continue with google"] + +[rate_limit] +text_patterns = [ + "message limit", + "rate limit", + "too many requests", + "you've reached", + "limite de messages", + "trop de requêtes", + "réessayez plus tard", +] + +[timeouts] +page_load = 30 +response_generation = 120 +response_timeout = 180 + +[input] +poll_interval_ms = 400 +quiet_ms = 1500 +inject_method = "auto" + +[options] +supports_images = true +supports_streaming = true +supports_new_chat = true + +# Multi-account: each account is a separate browser profile, so several +# subscriptions can be driven side by side. Declare them like this and address +# them with model = "claude@work". +# +# [[accounts]] +# id = "perso" +# email = "bruno.charest@gmail.com" +# default = true +# +# [[accounts]] +# id = "work" +# email = "brunocharest.bc@gmail.com" diff --git a/providers/deepseek.toml b/providers/deepseek.toml new file mode 100644 index 0000000..6dc7542 --- /dev/null +++ b/providers/deepseek.toml @@ -0,0 +1,73 @@ +# DeepSeek (chat.deepseek.com) provider configuration, driven entirely by this +# file. Selectors change when the web UI is updated: edit them here (reloaded +# live), never in the Rust source. Validate with: llm-gateway selftest deepseek +# +# The values below combine the public chat.deepseek.com DOM with published +# selector lists (May 2026). They are a starting point, not a guarantee: run +# 'llm-gateway selftest deepseek' before relying on them. + +[provider] +name = "deepseek" +display_name = "DeepSeek" +web_url = "https://chat.deepseek.com/" +new_conversation_url = "https://chat.deepseek.com/" +# Captures the web conversation id from the current URL (one capture group). +conversation_url_pattern = '^https://chat\.deepseek\.com/a/chat/s/([0-9a-zA-Z-]{8,})' +default_models = ["deepseek-chat", "deepseek-reasoner"] +enabled = true + +[selectors] +# The composer is a plain +

+  
+
+
+
+
diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs
new file mode 100644
index 0000000..2513f95
--- /dev/null
+++ b/src/dashboard/mod.rs
@@ -0,0 +1,4 @@
+//! The static dashboard page embedded in the binary.
+
+/// Raw HTML of the dashboard.
+pub const INDEX_HTML: &str = include_str!("index.html");
diff --git a/src/error.rs b/src/error.rs
new file mode 100644
index 0000000..7dbf1d4
--- /dev/null
+++ b/src/error.rs
@@ -0,0 +1,210 @@
+//! Typed errors mapped onto the OpenAI error payload.
+
+use std::path::PathBuf;
+
+/// Convenience alias used across the crate.
+pub type Result = std::result::Result;
+
+/// Every failure mode the gateway can surface to a client.
+#[derive(Debug, thiserror::Error)]
+pub enum GatewayError {
+    #[error("model '{0}' does not match any configured provider")]
+    UnknownProvider(String),
+
+    /// The model string named a provider, but not one of its accounts.
+    #[error("{0}")]
+    UnknownAccount(String),
+
+    #[error("parameter '{param}' {detail}")]
+    UnsupportedParameter { param: String, detail: String },
+
+    #[error("{0}")]
+    UnsupportedContentType(String),
+
+    #[error("{0}")]
+    InvalidRequest(String),
+
+    #[error("invalid API key")]
+    InvalidApiKey,
+
+    #[error(
+        "provider '{provider}' is not signed in: run 'llm-gateway login {provider} --manual', sign in inside the window it opens, then close it (Google refuses browsers it sees as automated, so this path avoids that)"
+    )]
+    RequiresLogin { provider: String },
+
+    #[error(
+        "provider '{provider}' is showing a captcha challenge; solve it in the browser window"
+    )]
+    Captcha { provider: String },
+
+    #[error("provider '{provider}' is busy, retry later")]
+    ProviderBusy { provider: String, waited_ms: u64 },
+
+    #[error("provider '{provider}' is rate limiting: {detail}")]
+    UpstreamRateLimited { provider: String, detail: String },
+
+    #[error("browser profile {path} is locked by another browser instance; close it and retry")]
+    BrowserProfileLocked { path: PathBuf },
+
+    #[error("selector '{selector}' not found ({step})")]
+    SelectorMissing { selector: String, step: String },
+
+    #[error("provider '{provider}' reported an error: {message}")]
+    UpstreamError { provider: String, message: String },
+
+    #[error("browser engine unavailable: {0}")]
+    BrowserUnavailable(String),
+
+    #[error("timed out while {stage} after {timeout_s}s")]
+    CaptureTimeout { stage: String, timeout_s: u64 },
+
+    #[error("provider '{provider}' is misconfigured: {detail}")]
+    ProviderMisconfigured { provider: String, detail: String },
+
+    #[error("unsupported browser '{0}': only Chromium-based browsers speak CDP")]
+    UnsupportedBrowser(String),
+
+    #[error("provider '{0}' is not configured")]
+    ProviderNotConfigured(String),
+
+    #[error("HTTP request to {url} failed: {source}")]
+    Http {
+        url: String,
+        #[source]
+        source: reqwest::Error,
+    },
+
+    #[error("I/O error: {0}")]
+    Io(#[from] std::io::Error),
+
+    #[error("invalid configuration: {0}")]
+    Config(String),
+
+    #[error("internal error: {0}")]
+    Internal(String),
+
+    /// The caller went away before the answer was captured. Never serialised:
+    /// there is no client left to read it.
+    #[error("client disconnected before the answer was captured")]
+    Cancelled,
+}
+
+impl GatewayError {
+    pub fn invalid_request(msg: impl Into) -> Self {
+        Self::InvalidRequest(msg.into())
+    }
+
+    pub fn unsupported_param(param: impl Into, detail: impl Into) -> Self {
+        Self::UnsupportedParameter {
+            param: param.into(),
+            detail: detail.into(),
+        }
+    }
+
+    pub fn internal(msg: impl Into) -> Self {
+        Self::Internal(msg.into())
+    }
+
+    /// HTTP status returned to the OpenAI-compatible client.
+    pub fn http_status(&self) -> u16 {
+        match self {
+            Self::UnknownProvider(_) | Self::UnknownAccount(_) => 404,
+            Self::UnsupportedParameter { .. }
+            | Self::UnsupportedContentType(_)
+            | Self::InvalidRequest(_) => 400,
+            Self::InvalidApiKey | Self::RequiresLogin { .. } => 401,
+            Self::Captcha { .. } => 403,
+            Self::ProviderBusy { .. } | Self::UpstreamRateLimited { .. } => 429,
+            Self::BrowserProfileLocked { .. } => 409,
+            Self::BrowserUnavailable(_) | Self::ProviderMisconfigured { .. } => 503,
+            Self::CaptureTimeout { .. } => 504,
+            Self::Cancelled => 499,
+            _ => 500,
+        }
+    }
+
+    /// OpenAI-compatible error type field.
+    pub fn error_type(&self) -> &'static str {
+        match self.http_status() {
+            400 | 401 | 403 | 404 => "invalid_request_error",
+            429 => "rate_limit_error",
+            _ => "api_error",
+        }
+    }
+
+    /// Stable machine-readable code, also used as a log field.
+    pub fn code(&self) -> &'static str {
+        match self {
+            Self::UnknownProvider(_) => "model_not_found",
+            Self::UnknownAccount(_) => "account_not_found",
+            Self::UnsupportedParameter { .. } => "unsupported_parameter",
+            Self::UnsupportedContentType(_) => "unsupported_content_type",
+            Self::InvalidRequest(_) => "invalid_request",
+            Self::InvalidApiKey => "invalid_api_key",
+            Self::RequiresLogin { .. } => "requires_login",
+            Self::Captcha { .. } => "captcha_required",
+            Self::ProviderBusy { .. } => "provider_busy",
+            Self::UpstreamRateLimited { .. } => "upstream_rate_limit",
+            Self::BrowserProfileLocked { .. } => "browser_profile_locked",
+            Self::SelectorMissing { .. } => "selector_missing",
+            Self::UpstreamError { .. } => "upstream_error",
+            Self::BrowserUnavailable(_) => "browser_unavailable",
+            Self::CaptureTimeout { .. } => "upstream_timeout",
+            Self::ProviderMisconfigured { .. } => "provider_misconfigured",
+            Self::UnsupportedBrowser(_) => "unsupported_browser",
+            Self::ProviderNotConfigured(_) => "provider_not_configured",
+            Self::Http { .. } => "upstream_http_error",
+            Self::Io(_) => "io_error",
+            Self::Config(_) => "invalid_configuration",
+            Self::Internal(_) => "internal_error",
+            Self::Cancelled => "client_disconnected",
+        }
+    }
+
+    /// Name of the offending parameter, when the error is parameter-specific.
+    pub fn param(&self) -> Option<&str> {
+        match self {
+            Self::UnsupportedParameter { param, .. } => Some(param.as_str()),
+            _ => None,
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+
+    #[test]
+    fn codes_and_statuses_are_stable() {
+        let e = GatewayError::RequiresLogin {
+            provider: "chatgpt".into(),
+        };
+        assert_eq!(e.http_status(), 401);
+        assert_eq!(e.code(), "requires_login");
+        assert_eq!(e.error_type(), "invalid_request_error");
+        assert!(e.to_string().contains("llm-gateway login chatgpt"));
+
+        let e = GatewayError::CaptureTimeout {
+            stage: "waiting for the answer".into(),
+            timeout_s: 180,
+        };
+        assert_eq!(e.http_status(), 504);
+        assert_eq!(e.code(), "upstream_timeout");
+
+        let e = GatewayError::unsupported_param("tools", "is not supported by the browser backend");
+        assert_eq!(e.param(), Some("tools"));
+        assert_eq!(e.http_status(), 400);
+
+        let e = GatewayError::BrowserProfileLocked {
+            path: PathBuf::from("/p"),
+        };
+        assert_eq!(e.http_status(), 409);
+
+        let e = GatewayError::UnknownAccount("provider 'chatgpt' has no account 'x'".into());
+        assert_eq!(e.http_status(), 404);
+        assert_eq!(e.code(), "account_not_found");
+        assert_eq!(e.error_type(), "invalid_request_error");
+        assert!(e.to_string().contains("no account 'x'"));
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..2635170
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,29 @@
+//! llm-bridge: an OpenAI-compatible gateway driven by a real browser session.
+
+pub mod browser;
+pub mod cli;
+pub mod config;
+pub mod conversation;
+pub mod dashboard;
+pub mod error;
+pub mod markdown;
+pub mod paths;
+pub mod providers;
+pub mod selftest;
+pub mod server;
+pub mod tokens;
+pub mod util;
+
+/// Version reported by the API.
+pub const API_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// Version plus the moment this binary was compiled.
+///
+/// Printed by `llm-gateway --version` and in the start-up banner, so it is
+/// obvious whether an installed binary matches the sources it came from.
+pub const VERSION: &str = concat!(
+    env!("CARGO_PKG_VERSION"),
+    " (built ",
+    env!("LLM_GATEWAY_BUILD_TIME"),
+    ")"
+);
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..7723227
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,1245 @@
+//! llm-gateway command line entry point.
+
+use std::io::Write;
+use std::path::PathBuf;
+use std::process::ExitCode;
+use std::sync::{Arc, OnceLock};
+use std::time::{Duration, Instant};
+
+use anyhow::{anyhow, bail, Context, Result};
+use clap::Parser;
+use llm_bridge::browser::{dom, executable_hint, external_launch, resolve_executable};
+use llm_bridge::cli::{
+    Cli, Command, ConfigCommand, ConversationsCommand, GlobalArgs, ServeArgs, TestArgs,
+};
+use llm_bridge::config::{CliOverrides, GlobalConfig, MarkdownMode, StealthMode};
+use llm_bridge::conversation::ThreadStore;
+use llm_bridge::error::GatewayError;
+use llm_bridge::paths::Layout;
+use llm_bridge::providers::{
+    owner_label, parse_model, ChatBackend, LlmProvider, MockBackend, ProviderRegistry, TurnEvent,
+    TurnRequest, WebUiProvider, IMPLICIT_ACCOUNT,
+};
+use llm_bridge::selftest;
+use llm_bridge::server::{self, AppState, BufferLayer, LogBuffer};
+use llm_bridge::util;
+use tracing_subscriber::prelude::*;
+use tracing_subscriber::{fmt, EnvFilter};
+
+/// Keeps the non-blocking file appender alive for the whole process.
+static FILE_GUARD: OnceLock = OnceLock::new();
+
+fn main() -> ExitCode {
+    let cli = Cli::parse();
+    let runtime = match tokio::runtime::Builder::new_multi_thread()
+        .enable_all()
+        .build()
+    {
+        Ok(runtime) => runtime,
+        Err(err) => {
+            eprintln!("error: cannot start the async runtime: {err}");
+            return ExitCode::from(1);
+        }
+    };
+    match runtime.block_on(run(cli)) {
+        Ok(code) => code,
+        Err(err) => {
+            eprintln!("error: {err:#}");
+            ExitCode::from(1)
+        }
+    }
+}
+
+async fn run(cli: Cli) -> Result {
+    let layout = Layout::discover()?;
+    layout.ensure().context("creating the state directory")?;
+
+    let config_path = match &cli.global.config {
+        Some(path) => llm_bridge::paths::expand_tilde(&path.to_string_lossy()),
+        None => layout.config_file(),
+    };
+    let mut config = GlobalConfig::load(&config_path)?;
+    let overrides = build_overrides(&cli.global)?;
+    config.apply(&overrides);
+    config.validate()?;
+
+    let logs = LogBuffer::new(500);
+    init_tracing(&layout, &config, &logs);
+
+    let command = cli.command.unwrap_or(Command::Serve(ServeArgs {
+        backend: "browser".to_string(),
+    }));
+    match command {
+        Command::Serve(args) => {
+            serve(
+                layout,
+                config,
+                overrides.clone(),
+                logs,
+                args,
+                cli.global.provider,
+            )
+            .await
+        }
+        Command::Test(args) => test(layout, config, args).await,
+        Command::Selftest(args) => selftest_command(layout, config, args).await,
+        Command::List(args) => list(layout, config, args.json).await,
+        Command::Login(args) => login(layout, config, args).await,
+        Command::Logout(args) => logout(layout, config, args, cli.global.yes).await,
+        Command::Conversations(args) => {
+            conversations(layout, config, args.command, cli.global.yes).await
+        }
+        Command::Reload => reload(config).await,
+        Command::Config(args) => config_command(layout, config, args.command),
+    }
+}
+
+/// Translate the command line flags into configuration overrides.
+fn build_overrides(global: &GlobalArgs) -> Result {
+    let browser_executable = match (&global.browser_path, &global.browser) {
+        (Some(path), _) => Some(path.to_string_lossy().to_string()),
+        (None, Some(name)) => executable_hint(name)?.map(|path| path.to_string_lossy().to_string()),
+        (None, None) => None,
+    };
+
+    let stealth = match &global.stealth {
+        Some(raw) => Some(StealthMode::parse(raw)?),
+        None => None,
+    };
+
+    let markdown = match &global.markdown {
+        Some(raw) => Some(MarkdownMode::parse(raw)?),
+        None => None,
+    };
+
+    Ok(CliOverrides {
+        host: global.host.clone(),
+        port: global.port,
+        browser_executable,
+        headless: if global.headless { Some(true) } else { None },
+        profile_dir: global
+            .profile_dir
+            .as_ref()
+            .map(|path| path.to_string_lossy().to_string()),
+        max_tabs: global.max_tabs,
+        debug_screenshots: if global.debug { Some(true) } else { None },
+        log_level: global.log_level.clone(),
+        log_format: global.log_format.clone(),
+        api_key: global.api_key.clone(),
+        attach: if global.attach { Some(true) } else { None },
+        debug_port: global.debug_port,
+        stealth,
+        markdown,
+    })
+}
+
+/// Split a `provider[@account]` argument into its two parts.
+fn split_owner(raw: &str) -> (String, Option) {
+    let parsed = parse_model(raw);
+    (parsed.provider, parsed.account)
+}
+
+/// Install the tracing subscriber, feeding both stderr and the dashboard buffer.
+fn init_tracing(layout: &Layout, config: &GlobalConfig, logs: &LogBuffer) {
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
+        EnvFilter::new(format!(
+            // Some Chrome versions emit CDP events chromiumoxide cannot decode;
+            // they are harmless and very noisy, so keep that target quiet.
+            "{},chromiumoxide=error,hyper=warn,tungstenite=warn,h2=warn",
+            config.logging.level
+        ))
+    });
+
+    let json = config.logging.format.eq_ignore_ascii_case("json");
+    let stderr_layer = if json {
+        fmt::layer().json().with_writer(std::io::stderr).boxed()
+    } else {
+        fmt::layer().with_writer(std::io::stderr).boxed()
+    };
+
+    let file_layer = {
+        let path = config.resolved_log_file(layout);
+        if let Some(parent) = path.parent() {
+            let _ = std::fs::create_dir_all(parent);
+        }
+        match std::fs::File::create(&path) {
+            Ok(file) => {
+                let (writer, guard) = tracing_appender::non_blocking(file);
+                let _ = FILE_GUARD.set(guard);
+                Some(fmt::layer().json().with_writer(writer).boxed())
+            }
+            Err(_) => None,
+        }
+    };
+
+    let _ = tracing_subscriber::registry()
+        .with(filter)
+        .with(stderr_layer)
+        .with(file_layer)
+        .with(BufferLayer::new(logs.clone()))
+        .try_init();
+}
+
+// ---------------------------------------------------------------------------
+// serve
+// ---------------------------------------------------------------------------
+
+async fn serve(
+    layout: Layout,
+    config: GlobalConfig,
+    overrides: CliOverrides,
+    logs: LogBuffer,
+    args: ServeArgs,
+    provider_filter: Option,
+) -> Result {
+    let mock = match args.backend.to_lowercase().as_str() {
+        "browser" | "cdp" | "chrome" => false,
+        "mock" => true,
+        other => bail!("unknown backend '{other}', expected 'browser' or 'mock'"),
+    };
+
+    let registry = Arc::new(ProviderRegistry::new(layout.clone(), config.clone()));
+    let report = registry.reload().await?;
+    for name in &report.added {
+        tracing::info!(provider = %name, "provider registered");
+    }
+
+    if let Some(name) = &provider_filter {
+        if !registry.retain_only(name).await {
+            bail!(
+                "provider '{name}' is not configured (state directory: {})",
+                layout.providers_dir().display()
+            );
+        }
+    }
+
+    let backend: Arc = if mock {
+        tracing::info!("using the mock backend: no browser will be launched");
+        let descriptors = ChatBackend::providers(registry.as_ref());
+        let per_chunk = Duration::from_millis(20);
+        if descriptors.is_empty() {
+            Arc::new(MockBackend::new(
+                vec![("chatgpt", "gpt-4o"), ("claude", "claude-sonnet-4")],
+                per_chunk,
+            ))
+        } else {
+            Arc::new(MockBackend::from_descriptors(descriptors, per_chunk))
+        }
+    } else {
+        registry.clone()
+    };
+
+    let store = Arc::new(ThreadStore::open(&config.resolved_store_path(&layout)).await?);
+    let state = AppState {
+        backend,
+        store,
+        layout: Arc::new(layout.clone()),
+        config: registry.config_handle(),
+        cli_overrides: Arc::new(overrides.clone()),
+        logs: logs.clone(),
+        metrics: Default::default(),
+        started_at: Instant::now(),
+    };
+
+    // Keep the watcher alive for the lifetime of the server.
+    let _watcher = spawn_config_watcher(&layout, Arc::clone(®istry), overrides);
+
+    if config.browser.attach {
+        warn_if_no_browser_is_listening(&config, &layout, ®istry);
+    }
+
+    let app = server::router(state);
+    let address = format!("{}:{}", config.server.host, config.server.port);
+    let listener = match tokio::net::TcpListener::bind(&address).await {
+        Ok(listener) => listener,
+        Err(err) => {
+            bail!(
+                "cannot listen on {address}: {err}\n\
+                 Another application is most likely using that port. Either start the \
+                 gateway elsewhere:\n    llm-gateway serve --port 8090\n\
+                 or make it permanent by setting port = 8090 in the [server] section of {}",
+                layout.config_file().display()
+            );
+        }
+    };
+
+    println!(
+        "llm-gateway {VERSION} listening on http://{address}",
+        VERSION = llm_bridge::VERSION
+    );
+    println!("  dashboard   http://{address}/dashboard");
+    println!("  models      http://{address}/v1/models");
+    println!("  state dir   {}", layout.root().display());
+    if let Some(name) = &provider_filter {
+        println!("  provider    {name} (only)");
+    }
+    if config.server.is_public_bind() && config.server.api_key.is_empty() {
+        eprintln!("warning: {address} is reachable from the network and no API key is configured");
+    }
+
+    axum::serve(listener, app)
+        .with_graceful_shutdown(shutdown_signal())
+        .await?;
+    registry.shutdown_all().await;
+    Ok(ExitCode::SUCCESS)
+}
+
+/// Attach mode needs a browser that is already running on the debug port.
+fn warn_if_no_browser_is_listening(
+    config: &GlobalConfig,
+    layout: &Layout,
+    registry: &Arc,
+) {
+    let address = format!("127.0.0.1:{}", config.browser.debug_port);
+    let reachable = address
+        .parse()
+        .ok()
+        .and_then(|socket| {
+            std::net::TcpStream::connect_timeout(&socket, Duration::from_secs(2)).ok()
+        })
+        .is_some();
+    if reachable {
+        tracing::info!(address = %address, "attach mode: a browser is listening");
+        return;
+    }
+
+    let first = registry.all().first().cloned();
+    let url = first
+        .as_ref()
+        .map(|provider| provider.cfg().web_url().to_string())
+        .unwrap_or_else(|| "about:blank".to_string());
+    let profile = first
+        .as_ref()
+        .map(|provider| config.profile_dir_for(layout, provider.name()))
+        .unwrap_or_else(|| config.resolved_profile_root(layout));
+    let configured = llm_bridge::paths::expand_tilde(&config.browser.executable);
+    let explicit = if config.browser.executable.trim().is_empty() {
+        None
+    } else {
+        Some(&configured)
+    };
+    let executable = resolve_executable(explicit, std::env::var("CHROME").ok());
+
+    eprintln!(
+        "warning: attach mode is on but nothing listens on {address}\n\
+           Start a browser first:\n    {}",
+        external_launch(
+            executable.as_ref(),
+            &profile,
+            &url,
+            Some(config.browser.debug_port)
+        )
+        .command_line()
+    );
+}
+
+/// Watch the provider directory and the configuration file for edits.
+fn spawn_config_watcher(
+    layout: &Layout,
+    registry: Arc,
+    overrides: CliOverrides,
+) -> Option {
+    use notify::{RecursiveMode, Watcher};
+
+    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(16);
+    let mut watcher = notify::recommended_watcher(move |result: notify::Result| {
+        if result.is_ok() {
+            let _ = tx.blocking_send(());
+        }
+    })
+    .ok()?;
+
+    if watcher
+        .watch(&layout.providers_dir(), RecursiveMode::NonRecursive)
+        .is_err()
+    {
+        return None;
+    }
+    let _ = watcher.watch(&layout.config_file(), RecursiveMode::NonRecursive);
+
+    let config_path = layout.config_file();
+    let providers_dir = layout.providers_dir();
+    tokio::spawn(async move {
+        while rx.recv().await.is_some() {
+            tokio::time::sleep(Duration::from_millis(500)).await;
+            while rx.try_recv().is_ok() {}
+
+            match GlobalConfig::load(&config_path) {
+                Ok(mut config) => {
+                    config.apply(&overrides);
+                    if let Err(err) = config.validate() {
+                        tracing::warn!(error = %err, "reloaded configuration is invalid, keeping the previous one");
+                    } else {
+                        registry.replace_global(config);
+                    }
+                }
+                Err(err) => tracing::warn!(error = %err, "cannot reload the configuration file"),
+            }
+
+            match registry.reload().await {
+                Ok(report) => {
+                    if !report.added.is_empty()
+                        || !report.updated.is_empty()
+                        || !report.removed.is_empty()
+                    {
+                        tracing::info!(
+                            added = ?report.added,
+                            updated = ?report.updated,
+                            removed = ?report.removed,
+                            "provider configuration reloaded"
+                        );
+                    }
+                }
+                Err(err) => tracing::warn!(error = %err, "cannot reload the providers"),
+            }
+            tracing::debug!(dir = %providers_dir.display(), "watcher woke up");
+        }
+    });
+
+    Some(watcher)
+}
+
+async fn shutdown_signal() {
+    let _ = tokio::signal::ctrl_c().await;
+    println!();
+    tracing::info!("shutting down");
+}
+
+// ---------------------------------------------------------------------------
+// test / selftest / list
+// ---------------------------------------------------------------------------
+
+async fn test(layout: Layout, config: GlobalConfig, args: TestArgs) -> Result {
+    let registry = build_registry(layout, config).await?;
+    let targets = select_providers(®istry, args.provider.as_deref(), args.all)?;
+    let prompt = args
+        .prompt
+        .unwrap_or_else(|| "Say 'ok' and nothing else.".to_string());
+
+    let mut all_ok = true;
+    let mut results = Vec::new();
+    for (name, account) in targets {
+        let label = owner_label(&name, account.as_deref());
+        let handle = registry
+            .get(&name)
+            .ok_or_else(|| anyhow!("provider '{name}' is not configured"))?;
+        let started = Instant::now();
+        let answer = test_target(&handle, account.as_deref(), &prompt, args.json).await;
+        let elapsed = started.elapsed().as_millis() as u64;
+        // Close the browser this target started before moving on, even when the
+        // turn failed, so a provider with several accounts never keeps more than
+        // one of them alive.
+        shutdown_target(&handle, account.as_deref()).await;
+
+        match answer {
+            Err(err) => {
+                all_ok = false;
+                results.push(serde_json::json!({
+                    "provider": label,
+                    "ok": false,
+                    "elapsed_ms": elapsed,
+                    "error": err.to_string(),
+                }));
+                if !args.json {
+                    println!("failed: {err}");
+                }
+            }
+            Ok(text) => {
+                results.push(serde_json::json!({
+                    "provider": label,
+                    "ok": !text.trim().is_empty(),
+                    "elapsed_ms": elapsed,
+                    "answer": text,
+                }));
+                if !args.json {
+                    println!();
+                    println!(
+                        "-- {label}: {elapsed} ms, {} characters",
+                        text.chars().count()
+                    );
+                }
+            }
+        }
+    }
+
+    if args.json {
+        println!("{}", serde_json::to_string_pretty(&results)?);
+    }
+    Ok(if all_ok {
+        ExitCode::SUCCESS
+    } else {
+        ExitCode::from(2)
+    })
+}
+
+/// Send the probe prompt to one target and return the captured answer.
+///
+/// Deltas are printed as they arrive unless the caller asked for JSON, in which
+/// case only the final text matters.
+async fn test_target(
+    handle: &Arc,
+    account: Option<&str>,
+    prompt: &str,
+    quiet: bool,
+) -> std::result::Result {
+    let session = handle
+        .session(account)
+        .await
+        .map_err(|err| GatewayError::Internal(format!("cannot open a browser session: {err}")))?;
+    let request = TurnRequest {
+        prompt: prompt.to_string(),
+        attachments: Vec::new(),
+        reuse_url: None,
+        replay_history: true,
+        probe: false,
+        trace_id: format!("cli-{}", uuid::Uuid::new_v4().simple()),
+        wait_for_tab: None,
+    };
+
+    if !quiet {
+        print!("{}: ", owner_label(handle.name(), account));
+        let _ = std::io::stdout().flush();
+    }
+
+    let mut stream = handle.run_turn(session, request).await?;
+    let mut text = String::new();
+    while let Some(event) = stream.recv().await {
+        match event {
+            TurnEvent::Delta(delta) => {
+                if !quiet {
+                    print!("{delta}");
+                    let _ = std::io::stdout().flush();
+                }
+                text.push_str(&delta);
+            }
+            TurnEvent::Completed(outcome) => return Ok(outcome.text),
+            TurnEvent::Failed(err) => return Err(err),
+        }
+    }
+    Ok(text)
+}
+
+async fn selftest_command(
+    layout: Layout,
+    config: GlobalConfig,
+    args: llm_bridge::cli::SelftestArgs,
+) -> Result {
+    let registry = build_registry(layout, config).await?;
+    let names = select_providers(®istry, args.provider.as_deref(), args.all)?;
+    let mut reports = Vec::new();
+    for (name, account) in names {
+        let label = owner_label(&name, account.as_deref());
+        let handle = registry
+            .get(&name)
+            .ok_or_else(|| anyhow!("provider '{name}' is not configured"))?;
+        if !args.json {
+            println!("checking {label}...");
+        }
+        let cfg = handle.cfg();
+        reports.push(selftest::validate_provider(handle.as_ref(), &cfg, account.as_deref()).await?);
+        shutdown_target(&handle, account.as_deref()).await;
+    }
+
+    if args.json {
+        println!("{}", selftest::render_json(&reports));
+    } else {
+        print!("{}", selftest::render_text(&reports));
+    }
+
+    Ok(if selftest::all_ok(&reports) {
+        ExitCode::SUCCESS
+    } else {
+        ExitCode::from(2)
+    })
+}
+
+async fn list(layout: Layout, config: GlobalConfig, json: bool) -> Result {
+    let registry = build_registry(layout.clone(), config.clone()).await?;
+    let descriptors = ChatBackend::providers(registry.as_ref());
+    if descriptors.is_empty() {
+        println!(
+            "no provider configured in {}",
+            layout.providers_dir().display()
+        );
+        return Ok(ExitCode::SUCCESS);
+    }
+
+    if json {
+        println!("{}", serde_json::to_string_pretty(&descriptors)?);
+        return Ok(ExitCode::SUCCESS);
+    }
+
+    for descriptor in &descriptors {
+        let state = if descriptor.usable {
+            "configured"
+        } else {
+            "misconfigured"
+        };
+        println!(
+            "{:<12} {:<10} {}",
+            descriptor.name, state, descriptor.display_name
+        );
+        println!("{:<12} web      {}", "", descriptor.web_url);
+        if !descriptor.models.is_empty() {
+            println!("{:<12} models   {}", "", descriptor.models.join(", "));
+        }
+        if let Some(browser) = &descriptor.browser {
+            println!("{:<12} engine   {}", "", browser);
+        }
+        for account in &descriptor.accounts {
+            let marker = if account.default { "*" } else { " " };
+            let identity = account
+                .email
+                .clone()
+                .or_else(|| account.label.clone())
+                .unwrap_or_else(|| "-".to_string());
+            println!(
+                "{:<12} {marker} {:<10} {}",
+                "",
+                util::truncate(&account.id, 10),
+                identity
+            );
+        }
+        for problem in &descriptor.problems {
+            println!("{:<12} problem  {}", "", problem);
+        }
+        println!();
+    }
+    println!(
+        "profiles: {}",
+        config.resolved_profile_root(&layout).display()
+    );
+    println!("run 'llm-gateway selftest ' to check a live session");
+    Ok(ExitCode::SUCCESS)
+}
+
+/// Resolve the providers (and accounts) a CLI command must act on.
+///
+/// A name may carry the account: `chatgpt@perso`. With `--all` every provider
+/// is used, and every account each provider declares.
+fn select_providers(
+    registry: &ProviderRegistry,
+    provider: Option<&str>,
+    all: bool,
+) -> Result)>> {
+    match (provider, all) {
+        (Some(raw), _) => {
+            let (name, account) = split_owner(raw);
+            let Some(handle) = registry.get(&name) else {
+                bail!(
+                    "provider '{name}' is not configured (known: {})",
+                    registry
+                        .all()
+                        .iter()
+                        .map(|provider| provider.name().to_string())
+                        .collect::>()
+                        .join(", ")
+                );
+            };
+            let cfg = handle.cfg();
+            let account = cfg
+                .resolve_account_id(account.as_deref())
+                .map_err(|detail| anyhow!("{detail}"))?;
+            Ok(vec![(name, account)])
+        }
+        (None, true) => {
+            // Every account is exercised, not just the default one: a provider
+            // that declares several profiles is only fully checked when each of
+            // them answers, and a signed-out second account is exactly the kind
+            // of thing --all exists to catch.
+            let mut targets: Vec<(String, Option)> = Vec::new();
+            for handle in registry.all() {
+                let name = handle.name().to_string();
+                let cfg = handle.cfg();
+                if cfg.has_explicit_accounts() {
+                    for account in cfg.accounts() {
+                        targets.push((name.clone(), Some(account.id.clone())));
+                    }
+                } else {
+                    targets.push((name, None));
+                }
+            }
+            if targets.is_empty() {
+                bail!("no provider is configured");
+            }
+            Ok(targets)
+        }
+        (None, false) => bail!("give a provider name or use --all"),
+    }
+}
+
+/// Stop the browser one CLI target started, without touching its siblings.
+///
+/// A provider with several accounts owns one browser per account, so shutting
+/// the provider down after each of them would close a session the next target
+/// is about to need.
+async fn shutdown_target(handle: &Arc, account: Option<&str>) {
+    let result = match account {
+        Some(account) => handle.shutdown_account(account).await,
+        None => handle.shutdown().await,
+    };
+    if let Err(err) = result {
+        eprintln!(
+            "warning: cannot stop the browser of {}: {err}",
+            owner_label(handle.name(), account)
+        );
+    }
+}
+
+async fn build_registry(layout: Layout, config: GlobalConfig) -> Result> {
+    let registry = Arc::new(ProviderRegistry::new(layout, config));
+    let report = registry.reload().await?;
+    for problem in &report.problems {
+        eprintln!("warning: {problem}");
+    }
+    if registry.all().is_empty() {
+        bail!("no provider is configured");
+    }
+    Ok(registry)
+}
+
+// ---------------------------------------------------------------------------
+// login / logout / conversations / reload / config
+// ---------------------------------------------------------------------------
+
+/// Explain how to sign in from a browser the user starts themselves.
+///
+/// This is the configuration OAuth providers such as Google accept: no
+/// automation switch and no debugging port is involved during the sign-in.
+fn print_manual_login(
+    provider: &str,
+    display_name: &str,
+    web_url: &str,
+    profile_dir: &std::path::Path,
+    config: &GlobalConfig,
+) -> Result {
+    let configured = llm_bridge::paths::expand_tilde(&config.browser.executable);
+    let explicit = if config.browser.executable.trim().is_empty() {
+        None
+    } else {
+        Some(&configured)
+    };
+    let executable = resolve_executable(explicit, std::env::var("CHROME").ok());
+    let plain = external_launch(executable.as_ref(), profile_dir, web_url, None);
+    let attached = external_launch(
+        executable.as_ref(),
+        profile_dir,
+        web_url,
+        Some(config.browser.debug_port),
+    );
+
+    println!("Sign in from a browser you start yourself: no automation switch and no");
+    println!("debugging port is involved, which is what OAuth providers such as Google");
+    println!("require. The gateway launches its own browser only for later requests.");
+    println!();
+    println!("The command to use (it opens the dedicated profile of {provider}, shown");
+    println!("below, without ever touching your personal Chrome profile):");
+    println!("    {}", plain.command_line());
+    println!();
+    println!("Then sign in to {display_name} in that window.");
+    println!();
+    println!("The session is kept in:");
+    println!("  {}", profile_dir.display());
+    println!();
+    println!("Alternative: keep that window open and let the gateway drive it instead.");
+    println!("Start it with a debugging port and set [browser] attach = true, or pass");
+    println!("--attach:");
+    println!("    {}", attached.command_line());
+    println!();
+    Ok(plain)
+}
+
+/// Ask a yes/no question that defaults to yes.
+async fn confirm_default_yes(question: &str) -> Result {
+    print!("{question} [Y/n] ");
+    std::io::stdout().flush()?;
+    let line = read_line_blocking().await?;
+    Ok(!matches!(
+        line.trim().to_lowercase().as_str(),
+        "n" | "no" | "non"
+    ))
+}
+
+async fn login(
+    layout: Layout,
+    mut config: GlobalConfig,
+    args: llm_bridge::cli::LoginArgs,
+) -> Result {
+    let (provider, named_account) = split_owner(&args.provider);
+    // A manual login needs a visible window.
+    config.browser.headless = false;
+    let registry = build_registry(layout.clone(), config.clone()).await?;
+    let handle = registry
+        .get(&provider)
+        .ok_or_else(|| anyhow!("provider '{provider}' is not configured"))?;
+    let cfg = handle.cfg();
+    let requested = args.account.clone().or(named_account);
+    let account = cfg
+        .resolve_account_id(requested.as_deref())
+        .map_err(|detail| anyhow!("{detail}"))?;
+    let label = owner_label(&provider, account.as_deref());
+    let profile_dir = handle
+        .profile_dir(account.as_deref())
+        .ok_or_else(|| anyhow!("provider '{provider}' has no browser profile"))?;
+    if cfg.has_explicit_accounts() {
+        println!("signing in {label} (profile {})", profile_dir.display());
+    }
+
+    if args.manual {
+        let launch = print_manual_login(
+            &label,
+            cfg.display_name(),
+            cfg.web_url(),
+            &profile_dir,
+            &config,
+        )?;
+
+        // Starting the window for the user is the whole point of this mode, but
+        // ask first so a scripted run can do it by hand.
+        if args.launch || confirm_default_yes("Open that browser window now?").await? {
+            match launch.spawn() {
+                Ok(child) => println!("browser started (pid {})", child.id()),
+                Err(err) => eprintln!(
+                    "cannot start the browser ({err}); run the command printed above by hand"
+                ),
+            }
+        }
+
+        println!();
+        println!(
+            "IMPORTANT: sign in to {} in that window first.",
+            cfg.display_name()
+        );
+        println!("  1. sign in with Google (or with an email address and password);");
+        println!("  2. wait until the chat interface is displayed;");
+        println!("  3. CLOSE that window completely, then press Enter here.");
+        println!();
+        read_line_blocking().await?;
+    }
+
+    let session = match handle.session(account.as_deref()).await {
+        Ok(session) => session,
+        Err(GatewayError::BrowserProfileLocked { .. }) => {
+            eprintln!(
+                "the profile is still in use: close the browser window you opened, then run \
+                 'llm-gateway login {label}' again"
+            );
+            return Ok(ExitCode::from(2));
+        }
+        Err(err) => return Err(err.into()),
+    };
+    let page = session.page_handle();
+    dom::goto(&page, cfg.web_url(), Duration::from_secs(60)).await?;
+
+    if !args.manual {
+        println!("A browser window is open on {}", cfg.web_url());
+        println!("Sign in once and complete any captcha; the session is kept in:");
+        println!("  {}", profile_dir.display());
+        println!("Press Enter here when you are signed in.");
+        read_line_blocking().await?;
+    }
+
+    let status = handle
+        .validate_session(account.as_deref(), &session)
+        .await?;
+    println!("session state: {}", status.state.as_str());
+    if let Some(detail) = &status.detail {
+        println!("detail: {detail}");
+    }
+
+    if !status.is_available() {
+        println!();
+        println!("The session is still not signed in. The sign-in has to happen inside the");
+        println!("window while it is open, before it is closed:");
+        println!("  llm-gateway login {label} --manual");
+        println!("  -> answer Y to open the window, sign in, wait for the chat to appear,");
+        println!("     then close the window and press Enter.");
+    }
+
+    handle.shutdown().await?;
+    Ok(if status.is_available() {
+        ExitCode::SUCCESS
+    } else {
+        ExitCode::from(2)
+    })
+}
+
+async fn logout(
+    layout: Layout,
+    config: GlobalConfig,
+    args: llm_bridge::cli::LogoutArgs,
+    yes: bool,
+) -> Result {
+    let root = config.resolved_profile_root(&layout);
+    let (named_provider, named_account) = match &args.provider {
+        Some(raw) => {
+            let (provider, account) = split_owner(raw);
+            (Some(provider), account)
+        }
+        None => (None, None),
+    };
+    let account = args.account.clone().or(named_account);
+
+    // Every (label, directory) pair the command must remove.
+    let mut targets: Vec<(String, PathBuf)> = Vec::new();
+    match (&named_provider, &account, args.all) {
+        (Some(provider), Some(account), _) => {
+            let registry = build_registry(layout.clone(), config.clone()).await?;
+            let handle = registry
+                .get(provider)
+                .ok_or_else(|| anyhow!("provider '{provider}' is not configured"))?;
+            let cfg = handle.cfg();
+            let resolved = cfg
+                .resolve_account_id(Some(account))
+                .map_err(|detail| anyhow!("{detail}"))?;
+            let dir = handle
+                .profile_dir(resolved.as_deref())
+                .ok_or_else(|| anyhow!("provider '{provider}' has no browser profile"))?;
+            targets.push((owner_label(provider, resolved.as_deref()), dir));
+        }
+        (Some(provider), None, true) => {
+            targets.push((provider.clone(), root.join(provider)));
+        }
+        (Some(provider), None, false) => {
+            let registry = build_registry(layout.clone(), config.clone()).await?;
+            let handle = registry
+                .get(provider)
+                .ok_or_else(|| anyhow!("provider '{provider}' is not configured"))?;
+            let cfg = handle.cfg();
+            if cfg.has_explicit_accounts() {
+                let known: Vec = cfg
+                    .accounts()
+                    .map(|account| {
+                        format!(
+                            "{} ({})",
+                            account.id,
+                            account.email.clone().unwrap_or_else(|| "-".into())
+                        )
+                    })
+                    .collect();
+                println!(
+                    "{provider} declares several accounts; name one with --account, or remove \
+                     them all with --all"
+                );
+                println!("  accounts: {}", known.join(", "));
+                return Ok(ExitCode::SUCCESS);
+            }
+            let dir = handle
+                .profile_dir(None)
+                .ok_or_else(|| anyhow!("provider '{provider}' has no browser profile"))?;
+            targets.push((provider.clone(), dir));
+        }
+        (None, _, true) => {
+            let entries = std::fs::read_dir(&root)
+                .map(|entries| {
+                    entries
+                        .flatten()
+                        .filter(|entry| entry.path().is_dir())
+                        .map(|entry| {
+                            (
+                                entry.file_name().to_string_lossy().to_string(),
+                                entry.path(),
+                            )
+                        })
+                        .collect::>()
+                })
+                .unwrap_or_default();
+            targets = entries;
+        }
+        (None, _, false) => bail!("give a provider name or use --all"),
+    }
+
+    if targets.is_empty() {
+        println!("no stored profile in {}", root.display());
+        return Ok(ExitCode::SUCCESS);
+    }
+
+    for (label, dir) in targets {
+        if !dir.exists() {
+            println!("{label}: no stored profile");
+            continue;
+        }
+        if !confirm(
+            &format!("delete the browser profile of {label} ({})?", dir.display()),
+            yes,
+        )
+        .await?
+        {
+            println!("{label}: skipped");
+            continue;
+        }
+        match std::fs::remove_dir_all(&dir) {
+            Ok(()) => println!("{label}: profile deleted"),
+            Err(err) => eprintln!(
+                "{label}: cannot delete {} ({err}); close the browser window opened by llm-gateway and retry",
+                dir.display()
+            ),
+        }
+    }
+    Ok(ExitCode::SUCCESS)
+}
+
+async fn conversations(
+    layout: Layout,
+    config: GlobalConfig,
+    command: ConversationsCommand,
+    yes: bool,
+) -> Result {
+    let store = ThreadStore::open(&config.resolved_store_path(&layout)).await?;
+    match command {
+        ConversationsCommand::List {
+            provider,
+            account,
+            json,
+        } => {
+            let (provider, account) = conversation_filter(provider.as_deref(), account);
+            let threads = store.list(provider.as_deref(), account.as_deref()).await;
+            if json {
+                let value: Vec = threads
+                    .iter()
+                    .map(|(id, record)| {
+                        serde_json::json!({ "conversation_id": id, "thread": record })
+                    })
+                    .collect();
+                println!("{}", serde_json::to_string_pretty(&value)?);
+                return Ok(ExitCode::SUCCESS);
+            }
+            if threads.is_empty() {
+                println!("no stored conversation");
+                return Ok(ExitCode::SUCCESS);
+            }
+            println!(
+                "{:<22} {:<10} {:<10} {:>6} {:<11} web conversation",
+                "conversation", "provider", "account", "turns", "state"
+            );
+            for (id, record) in threads {
+                println!(
+                    "{:<22} {:<10} {:<10} {:>6} {:<11} {}",
+                    util::truncate(&id, 20),
+                    record.provider,
+                    record.account.as_deref().unwrap_or(IMPLICIT_ACCOUNT),
+                    record.turns,
+                    match record.state {
+                        llm_bridge::conversation::ThreadState::Ready => "ready",
+                        llm_bridge::conversation::ThreadState::Incomplete => "incomplete",
+                    },
+                    record.web_url.unwrap_or_else(|| "-".to_string())
+                );
+            }
+        }
+        ConversationsCommand::Clear { provider, account } => {
+            let (provider, account) = conversation_filter(provider.as_deref(), account);
+            let scope = match (&provider, &account) {
+                (Some(provider), Some(account)) => format!("{provider}@{account}"),
+                (Some(provider), None) => provider.clone(),
+                (None, Some(account)) => format!("account '{account}' of every provider"),
+                (None, None) => "every provider".to_string(),
+            };
+            if confirm(&format!("delete the stored conversations of {scope}?"), yes).await? {
+                let removed = store.clear(provider.as_deref(), account.as_deref()).await?;
+                println!("{removed} conversation(s) removed");
+            } else {
+                println!("nothing removed");
+            }
+        }
+    }
+    Ok(ExitCode::SUCCESS)
+}
+
+/// Split a `provider[@account]` conversation filter into the two parts a store
+/// query takes. An explicit `--account` wins over the one in the provider name.
+fn conversation_filter(
+    provider: Option<&str>,
+    account: Option,
+) -> (Option, Option) {
+    match provider {
+        Some(raw) => {
+            let (name, named) = split_owner(raw);
+            (Some(name), account.or(named))
+        }
+        None => (None, account),
+    }
+}
+
+async fn reload(config: GlobalConfig) -> Result {
+    let url = format!(
+        "http://{}:{}/v1/admin/reload",
+        config.server.host, config.server.port
+    );
+    let client = reqwest::Client::new();
+    let mut request = client.post(&url);
+    if !config.server.api_key.is_empty() {
+        request = request.bearer_auth(&config.server.api_key);
+    }
+    let response = request.send().await.with_context(|| {
+        format!(
+            "POST {url} failed: is the server running, and on that port? start it with the \
+                 same --port (or set [server] port in the configuration file)"
+        )
+    })?;
+    let status = response.status();
+    let body = response.text().await.unwrap_or_default();
+    if status.is_success() {
+        println!("{body}");
+        Ok(ExitCode::SUCCESS)
+    } else {
+        bail!("reload failed with HTTP {status}: {body}")
+    }
+}
+
+fn config_command(
+    layout: Layout,
+    config: GlobalConfig,
+    command: ConfigCommand,
+) -> Result {
+    match command {
+        ConfigCommand::Path => println!("{}", layout.config_file().display()),
+        ConfigCommand::Init { force } => {
+            layout.ensure()?;
+            println!("state directory ready: {}", layout.root().display());
+            println!("  config    {}", layout.config_file().display());
+            println!("  providers {}", layout.providers_dir().display());
+
+            if force {
+                let written = layout.install_default_providers(true)?;
+                if written.is_empty() {
+                    println!("no provider file was replaced");
+                }
+                for path in written {
+                    println!(
+                        "  refreshed {} (previous version kept as {}.bak)",
+                        path.display(),
+                        path.display()
+                    );
+                }
+            }
+        }
+        ConfigCommand::Show => {
+            println!("{}", toml::to_string_pretty(&config)?);
+        }
+    }
+    Ok(ExitCode::SUCCESS)
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+async fn confirm(question: &str, assume_yes: bool) -> Result {
+    if assume_yes {
+        return Ok(true);
+    }
+    print!("{question} [y/N] ");
+    std::io::stdout().flush()?;
+    let line = read_line_blocking().await?;
+    Ok(matches!(
+        line.trim().to_lowercase().as_str(),
+        "y" | "yes" | "o" | "oui"
+    ))
+}
+
+async fn read_line_blocking() -> Result {
+    tokio::task::spawn_blocking(|| {
+        let mut line = String::new();
+        std::io::stdin().read_line(&mut line)?;
+        Ok::(line)
+    })
+    .await
+    .context("waiting for input")?
+    .context("reading input")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// A registry with a two-account provider and a single-account one.
+    async fn registry_with_accounts() -> (Arc, tempfile::TempDir) {
+        let tmp = tempfile::tempdir().unwrap();
+        let layout = Layout::rooted_at(tmp.path().join("state"));
+        layout.ensure().unwrap();
+        for name in ["chatgpt", "claude", "deepseek"] {
+            std::fs::remove_file(layout.provider_config(name)).ok();
+        }
+        let accounts = r#"
+[[accounts]]
+id = "perso"
+email = "bruno.charest@gmail.com"
+default = true
+
+[[accounts]]
+id = "pro"
+email = "brunocharest.bc@gmail.com"
+"#;
+        std::fs::write(
+            layout.provider_config("chatgpt"),
+            format!("{}{accounts}", include_str!("../providers/chatgpt.toml")),
+        )
+        .unwrap();
+        std::fs::write(
+            layout.provider_config("claude"),
+            include_str!("../providers/claude.toml"),
+        )
+        .unwrap();
+
+        let registry = Arc::new(ProviderRegistry::new(layout, GlobalConfig::default()));
+        registry.reload().await.unwrap();
+        (registry, tmp)
+    }
+
+    #[tokio::test]
+    async fn a_named_account_is_resolved_from_the_provider_argument() {
+        let (registry, _tmp) = registry_with_accounts().await;
+        let targets = select_providers(®istry, Some("chatgpt@pro"), false).unwrap();
+        assert_eq!(
+            targets,
+            vec![("chatgpt".to_string(), Some("pro".to_string()))]
+        );
+
+        // The default account is used when the name carries none.
+        let targets = select_providers(®istry, Some("chatgpt"), false).unwrap();
+        assert_eq!(
+            targets,
+            vec![("chatgpt".to_string(), Some("perso".to_string()))]
+        );
+
+        let err = select_providers(®istry, Some("chatgpt@nobody"), false).unwrap_err();
+        assert!(err.to_string().contains("no account 'nobody'"), "{err}");
+    }
+
+    #[tokio::test]
+    async fn all_covers_every_account_of_every_provider() {
+        let (registry, _tmp) = registry_with_accounts().await;
+        let targets = select_providers(®istry, None, true).unwrap();
+        assert_eq!(
+            targets,
+            vec![
+                ("chatgpt".to_string(), Some("perso".to_string())),
+                ("chatgpt".to_string(), Some("pro".to_string())),
+                // A provider without accounts keeps its single implicit one.
+                ("claude".to_string(), None),
+            ]
+        );
+    }
+
+    #[test]
+    fn the_conversation_filter_splits_the_provider_and_the_account() {
+        assert_eq!(
+            conversation_filter(Some("chatgpt@perso"), None),
+            (Some("chatgpt".to_string()), Some("perso".to_string()))
+        );
+        assert_eq!(
+            conversation_filter(Some("claude"), None),
+            (Some("claude".to_string()), None)
+        );
+        assert_eq!(
+            conversation_filter(None, Some("pro".to_string())),
+            (None, Some("pro".to_string()))
+        );
+        // An explicit flag wins over the one riding along with the provider.
+        assert_eq!(
+            conversation_filter(Some("chatgpt@perso"), Some("pro".to_string())),
+            (Some("chatgpt".to_string()), Some("pro".to_string()))
+        );
+    }
+}
diff --git a/src/markdown.rs b/src/markdown.rs
new file mode 100644
index 0000000..e66cb99
--- /dev/null
+++ b/src/markdown.rs
@@ -0,0 +1,1380 @@
+//! HTML to Markdown conversion for captured answers.
+//!
+//! The gateway reads answers from a signed-in browser page, so the text it
+//! forwards was written for a human looking at rendered HTML. `innerText` keeps
+//! the words and drops the structure: a table collapses into a column of
+//! unrelated lines and a code block loses its fences, so the next model in the
+//! chain cannot tell code from prose. Capturing `innerHTML` instead keeps that
+//! structure, and this module turns it back into Markdown.
+//!
+//! The converter is deliberately self-contained: no browser, no network and no
+//! HTML parser crate, because the markup is whatever a third party UI happens
+//! to ship. The tokenizer is tolerant by design, so malformed markup degrades
+//! into the readable text around it, and both parsing and rendering run on
+//! explicit stacks, so no input can overflow the stack, spin forever or panic.
+
+/// Depth beyond which start tags are ignored.
+///
+/// Real answers nest a handful of levels; the cap only stops a hostile page
+/// from making the converter allocate without bound.
+const MAX_DEPTH: usize = 128;
+
+/// Elements whose content is text rather than markup, so it is skipped whole.
+const RAW_TEXT_ELEMENTS: [&str; 4] = ["script", "style", "textarea", "title"];
+
+/// Elements that never have children and never need a closing tag.
+const VOID_ELEMENTS: [&str; 14] = [
+    "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
+    "track", "wbr",
+];
+
+/// Elements that are a block of their own and are followed by a blank line.
+const BLOCK_ELEMENTS: [&str; 12] = [
+    "p",
+    "div",
+    "section",
+    "article",
+    "header",
+    "footer",
+    "main",
+    "aside",
+    "figure",
+    "figcaption",
+    "dd",
+    "dt",
+];
+
+/// Convert the inner HTML of one answer node into Markdown.
+pub fn html_to_markdown(html: &str) -> String {
+    if html.trim().is_empty() {
+        return String::new();
+    }
+    let root = parse(html);
+    tidy(&render(&root.children))
+}
+
+/// True when the HTML contains a construct a plain text extraction would lose
+/// (a fenced code block, a table row, a list item, a heading, a link).
+pub fn has_rich_structure(html: &str) -> bool {
+    // Callers use this to choose between `innerText` and `innerHTML`, so a cheap
+    // approximate scan is enough; an exact answer would mean parsing twice.
+    const MARKERS: [&str; 12] = [
+        " String {
+    let mut out = String::with_capacity(markdown.len());
+    let mut pending_blank = false;
+    let mut fence: Option = None;
+
+    for raw_line in markdown.split('\n') {
+        // A carriage return only survives a CRLF document: line ending noise.
+        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
+
+        if let Some(opening) = fence.as_deref() {
+            // Inside a fence the text is code the user wrote, so it is copied
+            // through untouched, blank lines, indentation and trailing spaces
+            // included.
+            out.push_str(line);
+            out.push('\n');
+            if closes_fence(line, opening) {
+                fence = None;
+            }
+            continue;
+        }
+
+        let line = line.trim_end_matches([' ', '\t']);
+        if line.is_empty() {
+            // Remembered rather than written: a blank line only exists when
+            // something follows it, which also drops the ones at the end.
+            pending_blank = true;
+            continue;
+        }
+        if let Some(opening) = opening_fence(line) {
+            fence = Some(opening);
+        }
+        if pending_blank && !out.is_empty() {
+            out.push('\n');
+        }
+        pending_blank = false;
+        out.push_str(line);
+        out.push('\n');
+    }
+
+    out
+}
+
+/// The run of backticks that opens a fenced block, when a line starts one.
+fn opening_fence(line: &str) -> Option {
+    let run: String = line
+        .trim_start()
+        .chars()
+        .take_while(|character| *character == '`')
+        .collect();
+    if run.len() >= 3 {
+        Some(run)
+    } else {
+        None
+    }
+}
+
+/// True when a line closes the fence it is inside.
+fn closes_fence(line: &str, opening: &str) -> bool {
+    let trimmed = line.trim();
+    trimmed.len() >= opening.len() && trimmed.chars().all(|character| character == '`')
+}
+
+/// One node of the parsed document.
+#[derive(Debug)]
+enum Node {
+    Element(Element),
+    /// Entity-decoded text, otherwise exactly as written: whitespace is only
+    /// collapsed when the text is rendered, so a fenced block still reads it
+    /// verbatim.
+    Text(String),
+}
+
+/// One element: a lowercased name, decoded attributes and its children.
+#[derive(Debug, Default)]
+struct Element {
+    name: String,
+    attributes: Vec<(String, String)>,
+    children: Vec,
+}
+
+impl Element {
+    /// The value of an attribute, matched by its lowercased name.
+    fn attr(&self, name: &str) -> Option<&str> {
+        self.attributes
+            .iter()
+            .find(|(key, _)| key == name)
+            .map(|(_, value)| value.as_str())
+    }
+}
+
+/// Parse HTML into a tree, tolerating anything.
+///
+/// Text that no rule understands still ends up somewhere sensible, and an
+/// unterminated construct simply ends the document: a broken page must never
+/// cost an answer that is already half readable.
+fn parse(html: &str) -> Element {
+    let mut stack: Vec = vec![Element::default()];
+    let bytes = html.as_bytes();
+    let mut cursor = 0usize;
+
+    while cursor < bytes.len() {
+        if bytes.get(cursor) != Some(&b'<') {
+            let start = cursor;
+            while let Some(byte) = bytes.get(cursor) {
+                if *byte == b'<' {
+                    break;
+                }
+                cursor += 1;
+            }
+            if let Some(text) = html.get(start..cursor) {
+                push_text_node(&mut stack, text);
+            }
+            continue;
+        }
+
+        let rest = html.get(cursor..).unwrap_or("");
+
+        // Comments, doctypes and processing instructions carry no answer text.
+        if rest.starts_with("") {
+                Some(offset) => cursor + offset + 3,
+                None => bytes.len(),
+            };
+            continue;
+        }
+        if rest.starts_with("') {
+                Some(offset) => cursor + offset + 1,
+                None => bytes.len(),
+            };
+            continue;
+        }
+
+        if rest.starts_with("' {
+                    break;
+                }
+                end += 1;
+            }
+            let name = html.get(name_start..end).unwrap_or("").to_ascii_lowercase();
+            cursor = match html.get(end..).and_then(|tail| tail.find('>')) {
+                Some(offset) => end + offset + 1,
+                None => bytes.len(),
+            };
+            close_element(&mut stack, &name);
+            continue;
+        }
+
+        if rest
+            .as_bytes()
+            .get(1)
+            .is_some_and(|byte| byte.is_ascii_alphabetic())
+        {
+            let (name, attributes, self_closing, end) = read_start_tag(html, cursor);
+            cursor = end;
+            if name.is_empty() {
+                continue;
+            }
+            if RAW_TEXT_ELEMENTS.contains(&name.as_str()) && !self_closing {
+                cursor = skip_raw_text(html, end, &name);
+                continue;
+            }
+            let element = Element {
+                name: name.clone(),
+                attributes,
+                children: Vec::new(),
+            };
+            if self_closing || VOID_ELEMENTS.contains(&name.as_str()) || stack.len() >= MAX_DEPTH {
+                // A void element, a self-closing tag, or markup nested deeper
+                // than any real answer goes: the content is kept, the wrapper
+                // is not.
+                if let Some(parent) = stack.last_mut() {
+                    parent.children.push(Node::Element(element));
+                }
+            } else {
+                stack.push(element);
+            }
+            continue;
+        }
+
+        // A `<` that opens nothing is text, which is what a browser shows too.
+        push_text_node(&mut stack, "<");
+        cursor += 1;
+    }
+
+    close_until(&mut stack, 1);
+    stack.pop().unwrap_or_default()
+}
+
+/// Read one start tag: its lowercased name, its decoded attributes, whether it
+/// closes itself, and the position just past the final `>`.
+fn read_start_tag(html: &str, start: usize) -> (String, Vec<(String, String)>, bool, usize) {
+    let bytes = html.as_bytes();
+    let mut cursor = start + 1;
+    let name_start = cursor;
+    while let Some(byte) = bytes.get(cursor) {
+        if byte.is_ascii_whitespace() || *byte == b'>' || *byte == b'/' {
+            break;
+        }
+        cursor += 1;
+    }
+    let name = html
+        .get(name_start..cursor)
+        .unwrap_or("")
+        .to_ascii_lowercase();
+
+    let mut attributes: Vec<(String, String)> = Vec::new();
+    let mut self_closing = false;
+
+    loop {
+        let before = cursor;
+        while let Some(byte) = bytes.get(cursor) {
+            if byte.is_ascii_whitespace() {
+                cursor += 1;
+            } else {
+                break;
+            }
+        }
+
+        match bytes.get(cursor).copied() {
+            None => break,
+            Some(b'>') => {
+                cursor += 1;
+                break;
+            }
+            Some(b'/') => {
+                self_closing = true;
+                cursor += 1;
+            }
+            Some(_) => {
+                let attribute_start = cursor;
+                while let Some(byte) = bytes.get(cursor) {
+                    if byte.is_ascii_whitespace() || *byte == b'=' || *byte == b'>' || *byte == b'/'
+                    {
+                        break;
+                    }
+                    cursor += 1;
+                }
+                let attribute = html
+                    .get(attribute_start..cursor)
+                    .unwrap_or("")
+                    .to_ascii_lowercase();
+                while let Some(byte) = bytes.get(cursor) {
+                    if byte.is_ascii_whitespace() {
+                        cursor += 1;
+                    } else {
+                        break;
+                    }
+                }
+
+                let mut value = String::new();
+                if bytes.get(cursor) == Some(&b'=') {
+                    cursor += 1;
+                    while let Some(byte) = bytes.get(cursor) {
+                        if byte.is_ascii_whitespace() {
+                            cursor += 1;
+                        } else {
+                            break;
+                        }
+                    }
+                    match bytes.get(cursor).copied() {
+                        Some(quote) if quote == b'"' || quote == b'\'' => {
+                            cursor += 1;
+                            let value_start = cursor;
+                            while let Some(byte) = bytes.get(cursor) {
+                                if *byte == quote {
+                                    break;
+                                }
+                                cursor += 1;
+                            }
+                            value = html.get(value_start..cursor).unwrap_or("").to_string();
+                            // An unterminated quote runs to the end of the
+                            // document; the value stays a best effort.
+                            if bytes.get(cursor) == Some("e) {
+                                cursor += 1;
+                            }
+                        }
+                        None | Some(b'>') => {}
+                        Some(_) => {
+                            let value_start = cursor;
+                            while let Some(byte) = bytes.get(cursor) {
+                                if byte.is_ascii_whitespace() || *byte == b'>' {
+                                    break;
+                                }
+                                cursor += 1;
+                            }
+                            value = html.get(value_start..cursor).unwrap_or("").to_string();
+                        }
+                    }
+                }
+
+                if !attribute.is_empty() {
+                    attributes.push((attribute, decode_entities(&value)));
+                }
+            }
+        }
+
+        // Every turn consumes at least one byte; the guard is what keeps that
+        // true even for markup this reader has not thought of.
+        if cursor == before {
+            cursor += 1;
+        }
+    }
+
+    (name, attributes, self_closing, cursor)
+}
+
+/// Append a text node, decoding its entities on the way in.
+fn push_text_node(stack: &mut [Element], text: &str) {
+    if let Some(parent) = stack.last_mut() {
+        parent.children.push(Node::Text(decode_entities(text)));
+    }
+}
+
+/// Close the innermost open element with this name.
+///
+/// A stray end tag is ignored, while one that closes an outer element also
+/// closes the elements it still contains, which is what a browser does with
+/// mismatched markup.
+fn close_element(stack: &mut Vec, name: &str) {
+    if name.is_empty() {
+        return;
+    }
+    if let Some(index) = stack.iter().rposition(|element| element.name == name) {
+        close_until(stack, index);
+    }
+}
+
+/// Pop open elements down to `keep`, attaching each one to its new parent.
+fn close_until(stack: &mut Vec, keep: usize) {
+    while stack.len() > keep {
+        match stack.pop() {
+            Some(element) => {
+                if let Some(parent) = stack.last_mut() {
+                    parent.children.push(Node::Element(element));
+                }
+            }
+            None => return,
+        }
+    }
+}
+
+/// Skip the content of a raw text element, returning the position just past its
+/// end tag.
+fn skip_raw_text(html: &str, from: usize, name: &str) -> usize {
+    let needle = format!(" match html.get(index..).and_then(|tail| tail.find('>')) {
+            Some(offset) => index + offset + 1,
+            None => html.len(),
+        },
+        None => html.len(),
+    }
+}
+
+/// Find `needle` in `haystack` from `from`, comparing ASCII case-insensitively.
+fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8], from: usize) -> Option {
+    if needle.is_empty() || haystack.len() < needle.len() {
+        return None;
+    }
+    let last = haystack.len() - needle.len();
+    let mut index = from;
+    while index <= last {
+        let window = haystack.get(index..index + needle.len())?;
+        if window.eq_ignore_ascii_case(needle) {
+            return Some(index);
+        }
+        index += 1;
+    }
+    None
+}
+
+/// Decode the HTML entities that show up in captured answers.
+///
+/// Anything unrecognised is left exactly as it was written, because a stray
+/// ampersand in prose is far more common than an entity this table has missed.
+fn decode_entities(text: &str) -> String {
+    if !text.contains('&') {
+        return text.to_string();
+    }
+    let mut out = String::with_capacity(text.len());
+    let mut rest = text;
+    while let Some(index) = rest.find('&') {
+        out.push_str(rest.get(..index).unwrap_or(""));
+        let tail = rest.get(index..).unwrap_or("");
+        match decode_one(tail) {
+            Some((decoded, consumed)) => {
+                out.push(decoded);
+                rest = tail.get(consumed..).unwrap_or("");
+            }
+            None => {
+                out.push('&');
+                rest = tail.get(1..).unwrap_or("");
+            }
+        }
+    }
+    out.push_str(rest);
+    out
+}
+
+/// Decode the single entity a string starts with, with its length in bytes.
+fn decode_one(tail: &str) -> Option<(char, usize)> {
+    let body = tail.strip_prefix('&')?;
+    let end = body.find(';')?;
+    let name = body.get(..end)?;
+    // A bare ampersand must not swallow the rest of a sentence, so only a short
+    // reference is considered at all.
+    if name.is_empty() || name.len() > 32 {
+        return None;
+    }
+
+    let decoded = if let Some(digits) = name.strip_prefix('#') {
+        let code = match digits.strip_prefix(['x', 'X']) {
+            Some(hex) => u32::from_str_radix(hex, 16).ok()?,
+            None => digits.parse::().ok()?,
+        };
+        match char::from_u32(code) {
+            // Out of range or a lone surrogate: the replacement character is
+            // the honest answer, and it cannot panic.
+            Some(character) if character != '\0' => character,
+            _ => '\u{fffd}',
+        }
+    } else {
+        named_entity(name)?
+    };
+
+    Some((decoded, end + 2))
+}
+
+/// The character behind a named entity, or `None` when it is not one we know.
+fn named_entity(name: &str) -> Option {
+    // Matching ignores case: pages spell `&` and `&Nbsp;` too.
+    let character = match name.to_ascii_lowercase().as_str() {
+        "amp" => '&',
+        "lt" => '<',
+        "gt" => '>',
+        "quot" => '"',
+        "apos" => '\'',
+        "nbsp" => '\u{a0}',
+        "mdash" => '—',
+        "ndash" => '–',
+        "hellip" => '…',
+        "laquo" => '«',
+        "raquo" => '»',
+        "times" => '×',
+        "copy" => '©',
+        "reg" => '®',
+        "deg" => '°',
+        "middot" => '·',
+        "bull" => '•',
+        "eacute" => 'é',
+        "egrave" => 'è',
+        "agrave" => 'à',
+        "ccedil" => 'ç',
+        "uuml" => 'ü',
+        "ouml" => 'ö',
+        "auml" => 'ä',
+        _ => return None,
+    };
+    Some(character)
+}
+
+/// Collapse runs of layout whitespace into a single space, the way HTML itself
+/// does. A non-breaking space is content rather than layout and survives.
+fn collapse_whitespace(text: &str) -> String {
+    let mut out = String::with_capacity(text.len());
+    let mut in_space = false;
+    for character in text.chars() {
+        match character {
+            ' ' | '\t' | '\n' | '\r' | '\u{c}' => {
+                if !in_space {
+                    out.push(' ');
+                    in_space = true;
+                }
+            }
+            _ => {
+                out.push(character);
+                in_space = false;
+            }
+        }
+    }
+    out
+}
+
+/// One unit of work for the iterative renderer.
+enum Work<'a> {
+    /// Render a node of the parsed tree.
+    Node(&'a Node),
+    /// Append literal Markdown.
+    Emit(String),
+    /// Start a new line when the current one already holds something.
+    Break,
+    /// Push a buffer that the matching `Finish` will consume.
+    Capture(Capture),
+    /// Close the innermost capture and turn it into Markdown.
+    Finish,
+}
+
+/// Why a buffer is being captured: the text has to be complete before it can be
+/// wrapped (a link label), prefixed (a quote or a list item) or cut into cells.
+enum Capture {
+    Link { href: String },
+    Quote,
+    Item { ordered: bool, index: usize },
+    Row { header: bool, cells: Vec },
+    Cell { colspan: usize },
+}
+
+/// Render a slice of nodes into Markdown.
+///
+/// The walk runs on an explicit stack of work items - a node becomes the items
+/// that produce its Markdown - so the traversal stays flat however deeply the
+/// document nests.
+fn render(nodes: &[Node]) -> String {
+    let mut buffers: Vec = vec![String::new()];
+    let mut captures: Vec = Vec::new();
+    let mut work: Vec> = nodes.iter().rev().map(Work::Node).collect();
+
+    while let Some(item) = work.pop() {
+        match item {
+            Work::Emit(text) => push_text(&mut buffers, &text),
+            Work::Break => ensure_newline(&mut buffers),
+            Work::Capture(capture) => {
+                captures.push(capture);
+                buffers.push(String::new());
+            }
+            Work::Finish => finish_capture(&mut buffers, &mut captures),
+            Work::Node(Node::Text(text)) => {
+                let collapsed = collapse_whitespace(text);
+                // The indentation of the markup must not become a leading space
+                // on a fresh line, while on a line in progress it is what keeps
+                // `a b` from running into `ab`.
+                let collapsed = if at_line_start(&buffers) {
+                    collapsed.trim_start_matches(' ')
+                } else {
+                    collapsed.as_str()
+                };
+                if !collapsed.is_empty() {
+                    push_text(&mut buffers, collapsed);
+                }
+            }
+            Work::Node(Node::Element(element)) => push_element(element, &mut work),
+        }
+    }
+
+    // A capture left open would swallow its text, so anything still pending is
+    // unwound into the buffer below it.
+    while buffers.len() > 1 {
+        finish_capture(&mut buffers, &mut captures);
+    }
+    buffers.pop().unwrap_or_default()
+}
+
+/// Turn one element into the work items that render it.
+fn push_element<'a>(element: &'a Element, work: &mut Vec>) {
+    let mut parts: Vec> = Vec::new();
+
+    match element.name.as_str() {
+        // Markdown's hard break is two trailing spaces. `tidy` trims them, so
+        // the caller sees a plain newline, which is what a reader expects.
+        "br" => parts.push(Work::Emit("  \n".to_string())),
+        "hr" => {
+            parts.push(Work::Break);
+            parts.push(Work::Emit("---\n\n".to_string()));
+        }
+        "img" => {
+            let image = image_markdown(element);
+            if !image.is_empty() {
+                parts.push(Work::Emit(image));
+            }
+        }
+        // Fenced blocks and inline code spans read their own text, so their
+        // children are never walked as markup.
+        "pre" => {
+            parts.push(Work::Break);
+            parts.push(Work::Emit(fenced_block(element)));
+        }
+        "code" => {
+            let span = inline_code(element);
+            if !span.is_empty() {
+                parts.push(Work::Emit(span));
+            }
+        }
+        "blockquote" => {
+            parts.push(Work::Break);
+            parts.push(Work::Capture(Capture::Quote));
+            parts.extend(children(element));
+            parts.push(Work::Finish);
+        }
+        "ul" | "ol" => {
+            parts.push(Work::Break);
+            push_list(element, element.name == "ol", &mut parts);
+        }
+        "table" => {
+            parts.push(Work::Break);
+            push_table(element, &mut parts);
+        }
+        "a" => {
+            let href = element.attr("href").unwrap_or_default().to_string();
+            parts.push(Work::Capture(Capture::Link { href }));
+            parts.extend(children(element));
+            parts.push(Work::Finish);
+        }
+        "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
+            let level = heading_level(&element.name);
+            parts.push(Work::Break);
+            parts.push(Work::Emit(format!("{} ", "#".repeat(level))));
+            parts.extend(children(element));
+            parts.push(Work::Emit("\n\n".to_string()));
+        }
+        "strong" | "b" => parts.extend(wrapped("**", element)),
+        "em" | "i" => parts.extend(wrapped("*", element)),
+        "del" | "s" | "strike" => parts.extend(wrapped("~~", element)),
+        name if BLOCK_ELEMENTS.contains(&name) => {
+            parts.push(Work::Break);
+            parts.extend(children(element));
+            parts.push(Work::Emit("\n\n".to_string()));
+        }
+        // Unknown elements, and cells or items met outside their table or list,
+        // keep their content and nothing else.
+        _ => parts.extend(children(element)),
+    }
+
+    // The stack is popped from the back, so the parts go on in reverse.
+    for part in parts.into_iter().rev() {
+        work.push(part);
+    }
+}
+
+/// The heading level of an `h1`..`h6` element.
+fn heading_level(name: &str) -> usize {
+    name.as_bytes()
+        .last()
+        .map_or(1, |digit| usize::from(digit.saturating_sub(b'0')))
+        .clamp(1, 6)
+}
+
+/// Emphasised or struck-through content: a marker on either side.
+fn wrapped<'a>(marker: &str, element: &'a Element) -> Vec> {
+    let mut parts = vec![Work::Emit(marker.to_string())];
+    parts.extend(children(element));
+    parts.push(Work::Emit(marker.to_string()));
+    parts
+}
+
+/// One work item per child, in document order.
+fn children<'a>(element: &'a Element) -> Vec> {
+    element.children.iter().map(Work::Node).collect()
+}
+
+/// Add the items that render a `ul` or `ol`.
+fn push_list<'a>(element: &'a Element, ordered: bool, parts: &mut Vec>) {
+    let mut index = 0usize;
+    for child in &element.children {
+        match child {
+            Node::Element(item) if item.name == "li" => {
+                parts.push(Work::Capture(Capture::Item { ordered, index }));
+                parts.extend(children(item));
+                parts.push(Work::Finish);
+                index += 1;
+            }
+            // Anything a list holds besides its items is kept as it is.
+            other => parts.push(Work::Node(other)),
+        }
+    }
+    // A list is followed by a blank line, so the next block starts cleanly.
+    parts.push(Work::Emit("\n".to_string()));
+}
+
+/// Add the items that render a `table`.
+fn push_table<'a>(element: &'a Element, parts: &mut Vec>) {
+    let rows = table_rows(element);
+    if rows.is_empty() {
+        // A table with no rows holds nothing a reader would miss.
+        return;
+    }
+    // Markdown tables need a header row; when the markup names none, the first
+    // row takes that role, which is what a reader of the page would assume.
+    let has_header = rows.iter().any(|(_, header)| *header);
+    for (index, (row, header)) in rows.iter().enumerate() {
+        let header = *header || (!has_header && index == 0);
+        parts.push(Work::Capture(Capture::Row {
+            header,
+            cells: Vec::new(),
+        }));
+        for cell in row_cells(row) {
+            parts.push(Work::Capture(Capture::Cell {
+                colspan: colspan(cell),
+            }));
+            parts.extend(children(cell));
+            parts.push(Work::Finish);
+        }
+        parts.push(Work::Finish);
+    }
+    parts.push(Work::Emit("\n".to_string()));
+}
+
+/// The rows of a table in document order, each with the flag telling whether it
+/// is a header row (inside `thead`, or holding `th` cells).
+fn table_rows(table: &Element) -> Vec<(&Element, bool)> {
+    let mut rows = Vec::new();
+    for child in &table.children {
+        let Node::Element(child) = child else {
+            continue;
+        };
+        match child.name.as_str() {
+            "tr" => rows.push((child, row_has_th(child))),
+            "thead" | "tbody" | "tfoot" => {
+                let in_header = child.name == "thead";
+                for row in &child.children {
+                    if let Node::Element(row) = row {
+                        if row.name == "tr" {
+                            rows.push((row, in_header || row_has_th(row)));
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+    rows
+}
+
+/// True when a row holds header cells of its own.
+fn row_has_th(row: &Element) -> bool {
+    row_cells(row).iter().any(|cell| cell.name == "th")
+}
+
+/// The cells of a row, in document order.
+fn row_cells(row: &Element) -> Vec<&Element> {
+    row.children
+        .iter()
+        .filter_map(|child| match child {
+            Node::Element(cell) if cell.name == "th" || cell.name == "td" => Some(cell),
+            _ => None,
+        })
+        .collect()
+}
+
+/// How many columns a cell covers; a missing or silly value means one, and the
+/// cap keeps a hostile `colspan` from asking for an enormous row.
+fn colspan(cell: &Element) -> usize {
+    cell.attr("colspan")
+        .and_then(|value| value.trim().parse::().ok())
+        .filter(|span| *span > 0)
+        .map_or(1, |span| span.min(64))
+}
+
+/// Close the innermost capture and append its Markdown to the buffer below it.
+fn finish_capture(buffers: &mut Vec, captures: &mut Vec) {
+    let text = match buffers.pop() {
+        Some(text) => text,
+        None => return,
+    };
+
+    match captures.pop() {
+        Some(Capture::Link { href }) => {
+            let label = text.trim();
+            let rendered = if href.is_empty() {
+                label.to_string()
+            } else if label == href {
+                href
+            } else if label.is_empty() {
+                String::new()
+            } else {
+                format!("[{label}]({href})")
+            };
+            push_text(buffers, &rendered);
+        }
+        Some(Capture::Quote) => {
+            let mut quoted = String::new();
+            let body = text.trim();
+            if !body.is_empty() {
+                for line in body.split('\n') {
+                    let line = line.trim_end();
+                    if line.is_empty() {
+                        quoted.push_str(">\n");
+                    } else {
+                        quoted.push_str("> ");
+                        quoted.push_str(line);
+                        quoted.push('\n');
+                    }
+                }
+                quoted.push('\n');
+            }
+            push_text(buffers, "ed);
+        }
+        Some(Capture::Item { ordered, index }) => {
+            // The bullet shares its line with the first line of the item; the
+            // rest continues indented under it, two spaces per level.
+            let bullet = if ordered {
+                format!("{}.", index + 1)
+            } else {
+                "-".to_string()
+            };
+            let mut rendered = String::new();
+            for (number, line) in text.trim().split('\n').enumerate() {
+                let line = line.trim_end();
+                if number == 0 {
+                    rendered.push_str(&bullet);
+                    rendered.push(' ');
+                    rendered.push_str(line.trim_start());
+                } else if !line.is_empty() {
+                    rendered.push_str("  ");
+                    rendered.push_str(line);
+                }
+                rendered.push('\n');
+            }
+            push_text(buffers, &rendered);
+        }
+        Some(Capture::Row { header, cells }) => {
+            let mut rendered = String::new();
+            if !cells.is_empty() {
+                rendered.push_str("| ");
+                rendered.push_str(&cells.join(" | "));
+                rendered.push_str(" |\n");
+                if header {
+                    rendered.push_str("| ");
+                    rendered.push_str(&vec!["---"; cells.len()].join(" | "));
+                    rendered.push_str(" |\n");
+                }
+            }
+            push_text(buffers, &rendered);
+        }
+        Some(Capture::Cell { colspan }) => {
+            // A cell is a single line: newlines inside it become spaces, and a
+            // pipe would otherwise cut the row into extra columns.
+            let cell = text
+                .split_whitespace()
+                .collect::>()
+                .join(" ")
+                .replace('|', "\\|");
+            match captures.last_mut() {
+                Some(Capture::Row { cells, .. }) => {
+                    for _ in 0..colspan {
+                        cells.push(cell.clone());
+                    }
+                }
+                _ => push_text(buffers, &cell),
+            }
+        }
+        None => push_text(buffers, &text),
+    }
+}
+
+/// An image with its alternative text, as Markdown.
+fn image_markdown(element: &Element) -> String {
+    let alt = element.attr("alt").unwrap_or_default();
+    let src = element.attr("src").unwrap_or_default();
+    if alt.is_empty() && src.is_empty() {
+        // A decorative spacer: nothing a reader would miss.
+        return String::new();
+    }
+    format!("![{alt}]({src})")
+}
+
+/// An inline code span: one backtick, or two when the text contains one.
+fn inline_code(element: &Element) -> String {
+    let text = text_content(element);
+    let text = text.trim();
+    if text.is_empty() {
+        return String::new();
+    }
+    let ticks = if text.contains('`') { "``" } else { "`" };
+    format!("{ticks}{text}{ticks}")
+}
+
+/// A `pre` as a fenced block, named with the language of an inner `code`
+/// element when the page declares one.
+fn fenced_block(element: &Element) -> String {
+    let text = text_content(element);
+    let text = text.trim_matches(['\n', '\r']);
+    let language = code_language(element).unwrap_or_default();
+    let fence = fence_for(text);
+    format!("{fence}{language}\n{text}\n{fence}\n\n")
+}
+
+/// A fence longer than any run of backticks in the text, and at least three.
+fn fence_for(text: &str) -> String {
+    let mut longest = 0usize;
+    let mut run = 0usize;
+    for character in text.chars() {
+        if character == '`' {
+            run += 1;
+            longest = longest.max(run);
+        } else {
+            run = 0;
+        }
+    }
+    "`".repeat(longest.max(2) + 1)
+}
+
+/// The language named by `class="language-xxx"` on the `code` child of a `pre`.
+fn code_language(element: &Element) -> Option {
+    let code = element.children.iter().find_map(|child| match child {
+        Node::Element(child) if child.name == "code" => Some(child),
+        _ => None,
+    })?;
+    code.attr("class")?
+        .split_whitespace()
+        .find_map(|token| token.strip_prefix("language-"))
+        .filter(|language| !language.is_empty())
+        .map(|language| language.to_string())
+}
+
+/// The text of a subtree, with its whitespace exactly as written.
+///
+/// Fenced blocks and code spans use this instead of the renderer, because
+/// Markdown code is literal text rather than markup.
+fn text_content(element: &Element) -> String {
+    let mut out = String::new();
+    let mut stack: Vec<&Node> = element.children.iter().rev().collect();
+    while let Some(node) = stack.pop() {
+        match node {
+            Node::Text(text) => out.push_str(text),
+            Node::Element(child) => {
+                if child.name == "br" {
+                    out.push('\n');
+                }
+                stack.extend(child.children.iter().rev());
+            }
+        }
+    }
+    out
+}
+
+/// Append text to the innermost buffer.
+fn push_text(buffers: &mut [String], text: &str) {
+    if let Some(buffer) = buffers.last_mut() {
+        buffer.push_str(text);
+    }
+}
+
+/// End the current line when it already holds something.
+fn ensure_newline(buffers: &mut [String]) {
+    if let Some(buffer) = buffers.last_mut() {
+        if !buffer.is_empty() && !buffer.ends_with('\n') {
+            buffer.push('\n');
+        }
+    }
+}
+
+/// True when nothing has been written on the current line yet.
+fn at_line_start(buffers: &[String]) -> bool {
+    buffers
+        .last()
+        .is_none_or(|buffer| buffer.is_empty() || buffer.ends_with('\n'))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn empty_input_produces_empty_output() {
+        assert_eq!(html_to_markdown(""), "");
+        assert_eq!(html_to_markdown("   \n\t  "), "");
+    }
+
+    #[test]
+    fn paragraphs_keep_their_text_without_the_markup() {
+        assert_eq!(html_to_markdown("

Hello world

"), "Hello world\n"); + } + + #[test] + fn two_paragraphs_are_separated_by_a_blank_line() { + assert_eq!(html_to_markdown("

One

Two

"), "One\n\nTwo\n"); + } + + #[test] + fn layout_whitespace_never_leaves_blank_runs() { + assert_eq!(html_to_markdown("

a

\n\n\n

b

\n\n\n"), "a\n\nb\n"); + } + + #[test] + fn line_break_ends_the_line() { + // The renderer writes Markdown's two-space hard break; `tidy` trims the + // trailing spaces, so the caller sees a plain newline. + assert_eq!(html_to_markdown("

one
two

"), "one\ntwo\n"); + assert_eq!(html_to_markdown("

one
two

"), "one\ntwo\n"); + } + + #[test] + fn headings_use_one_hash_per_level() { + assert_eq!( + html_to_markdown("

Title

Deep

"), + "# Title\n\n#### Deep\n" + ); + } + + #[test] + fn emphasis_becomes_markdown_markers() { + let html = + "

bold also bold italic also gone

"; + assert_eq!( + html_to_markdown(html), + "**bold** **also bold** *italic* *also* ~~gone~~\n" + ); + } + + #[test] + fn inline_code_uses_one_backtick() { + assert_eq!( + html_to_markdown("

run cargo test now

"), + "run `cargo test` now\n" + ); + } + + #[test] + fn inline_code_containing_a_backtick_uses_two() { + assert_eq!( + html_to_markdown("

x `y` z

"), + "``x `y` z``\n" + ); + } + + #[test] + fn fenced_code_block_without_a_language() { + assert_eq!( + html_to_markdown("
fn main() {}
"), + "```\nfn main() {}\n```\n" + ); + } + + #[test] + fn fenced_code_block_takes_the_language_from_the_code_class() { + let html = r#"
let x = 1;
"#; + assert_eq!(html_to_markdown(html), "```rust\nlet x = 1;\n```\n"); + } + + #[test] + fn fenced_code_block_leaves_its_text_alone() { + let html = "
a < b && c\n   indented\n
"; + assert_eq!( + html_to_markdown(html), + "```\na < b && c\n indented\n```\n" + ); + } + + #[test] + fn fence_grows_when_the_code_contains_backticks() { + assert_eq!( + html_to_markdown("
a ``` b
"), + "````\na ``` b\n````\n" + ); + } + + #[test] + fn named_and_numeric_entities_are_decoded() { + assert_eq!( + html_to_markdown("

a & b <tag> AB

"), + "a & b AB\n" + ); + assert_eq!(html_to_markdown("

😀

"), "\u{1f600}\n"); + } + + #[test] + fn punctuation_entities_are_decoded() { + assert_eq!( + html_to_markdown("

5 × 3 — café

"), + "5 × 3 — café\n" + ); + } + + #[test] + fn unknown_entities_are_left_alone() { + assert_eq!( + html_to_markdown("

a &unknown; b & c

"), + "a &unknown; b & c\n" + ); + } + + #[test] + fn invalid_numeric_references_do_not_panic() { + assert_eq!( + html_to_markdown("

��

"), + "\u{fffd}\u{fffd}\n" + ); + } + + #[test] + fn non_breaking_spaces_are_kept() { + assert_eq!(html_to_markdown("

a b

"), "a\u{a0}b\n"); + } + + #[test] + fn attributes_are_decoded() { + let html = r#"docs"#; + assert_eq!(html_to_markdown(html), "[docs](https://x.test/?a=1&b=2)\n"); + } + + #[test] + fn attribute_names_are_case_insensitive() { + let html = r#"https://e.test"#; + assert_eq!(html_to_markdown(html), "https://e.test\n"); + } + + #[test] + fn attribute_values_may_be_quoted_or_bare() { + assert_eq!( + html_to_markdown("x"), + "[x](https://s.test)\n" + ); + assert_eq!( + html_to_markdown("pic"), + "![pic](/u/pic.png)\n" + ); + } + + #[test] + fn nested_lists_are_indented() { + let html = "
  • one
    • inner
  • two
"; + assert_eq!(html_to_markdown(html), "- one\n - inner\n- two\n"); + } + + #[test] + fn ordered_list_items_are_numbered() { + assert_eq!( + html_to_markdown("
  1. first
  2. second
"), + "1. first\n2. second\n" + ); + } + + #[test] + fn list_item_blocks_continue_on_indented_lines() { + assert_eq!( + html_to_markdown("
  • first

    second

"), + "- first\n second\n" + ); + } + + #[test] + fn blockquote_prefixes_every_line() { + let html = "

first

second

"; + assert_eq!(html_to_markdown(html), "> first\n>\n> second\n"); + } + + #[test] + fn link_with_a_different_text_uses_the_markdown_form() { + let html = r#"

see the docs

"#; + assert_eq!(html_to_markdown(html), "see [the docs](https://e.test)\n"); + } + + #[test] + fn link_whose_text_is_its_href_collapses_to_the_url() { + let html = r#"

https://e.test

"#; + assert_eq!(html_to_markdown(html), "https://e.test\n"); + } + + #[test] + fn image_uses_its_alt_text_and_source() { + let html = r#"

A picture

"#; + assert_eq!(html_to_markdown(html), "![A picture](/pic.png)\n"); + } + + #[test] + fn horizontal_rule_gets_its_own_line() { + assert_eq!( + html_to_markdown("

above


below

"), + "above\n\n---\n\nbelow\n" + ); + } + + #[test] + fn table_with_a_header_becomes_a_github_table() { + let html = "\ +
NameValue
a1
"; + assert_eq!( + html_to_markdown(html), + "| Name | Value |\n| --- | --- |\n| a | 1 |\n" + ); + } + + #[test] + fn table_without_a_header_promotes_the_first_row() { + let html = "
h1h2
ab
"; + assert_eq!( + html_to_markdown(html), + "| h1 | h2 |\n| --- | --- |\n| a | b |\n" + ); + } + + #[test] + fn pipes_inside_a_cell_are_escaped() { + let html = "
ab
x | yz
"; + assert_eq!( + html_to_markdown(html), + "| a | b |\n| --- | --- |\n| x \\| y | z |\n" + ); + } + + #[test] + fn colspan_repeats_the_cell_content() { + let html = + r#"
ab
span
"#; + assert_eq!( + html_to_markdown(html), + "| a | b |\n| --- | --- |\n| span | span |\n" + ); + } + + #[test] + fn a_table_without_rows_produces_nothing() { + assert_eq!(html_to_markdown("
"), ""); + } + + #[test] + fn unknown_elements_keep_their_content() { + let html = r#"text"#; + assert_eq!(html_to_markdown(html), "text\n"); + } + + #[test] + fn comments_doctypes_and_raw_text_are_dropped() { + let html = "\ +

kept

"; + assert_eq!(html_to_markdown(html), "kept\n"); + } + + #[test] + fn an_unclosed_tag_keeps_the_text() { + assert_eq!(html_to_markdown("

hello world"), "hello **world**\n"); + } + + #[test] + fn a_stray_angle_bracket_is_text() { + assert_eq!( + html_to_markdown("

2 < 3 and 4 > 1

"), + "2 < 3 and 4 > 1\n" + ); + } + + #[test] + fn an_unterminated_attribute_quote_stops_at_the_end() { + // The quoted value runs to the end of the document: everything before it + // is still rendered and nothing hangs. + let html = "

visible

tail"; + assert_eq!(html_to_markdown(html), "visible\n"); + } + + #[test] + fn a_stray_end_tag_is_ignored() { + assert_eq!(html_to_markdown("

bold

"), "**bold**\n"); + } + + #[test] + fn unterminated_constructs_terminate() { + assert_eq!(html_to_markdown("<>&&&"), "<>&&&\n"); + assert_eq!(html_to_markdown("

a

+ + + +
+ + +