From 7b5aa6fcc99f16ef5019daf2376ae5575120fd0e Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 18 Sep 2026 18:01:07 -0400 Subject: [PATCH] Add initial llm-bridge project --- .github/workflows/ci.yml | 98 ++ .gitignore | 3 + Cargo.lock | 2742 ++++++++++++++++++++++++++++++ Cargo.toml | 57 + README.md | 498 ++++++ build.rs | 44 + config.toml.example | 109 ++ demo.ps1 | 140 ++ demo.sh | 91 + providers/chatgpt.toml | 86 + providers/claude.toml | 79 + providers/deepseek.toml | 73 + providers/selectors.md | 169 ++ scripts/ci.ps1 | 76 + scripts/e2e.ps1 | 45 + src/browser/cdp.rs | 748 ++++++++ src/browser/debug.rs | 140 ++ src/browser/dom.rs | 193 +++ src/browser/engine.rs | 126 ++ src/browser/js.rs | 406 +++++ src/browser/mod.rs | 15 + src/browser/pool.rs | 237 +++ src/browser/session.rs | 453 +++++ src/cli.rs | 380 +++++ src/config.rs | 562 ++++++ src/conversation/fingerprint.rs | 99 ++ src/conversation/mod.rs | 7 + src/conversation/store.rs | 386 +++++ src/dashboard/index.html | 194 +++ src/dashboard/mod.rs | 4 + src/error.rs | 210 +++ src/lib.rs | 29 + src/main.rs | 1245 ++++++++++++++ src/markdown.rs | 1380 +++++++++++++++ src/paths.rs | 251 +++ src/providers/backend.rs | 127 ++ src/providers/config.rs | 882 ++++++++++ src/providers/mock.rs | 290 ++++ src/providers/mod.rs | 23 + src/providers/model.rs | 132 ++ src/providers/registry.rs | 591 +++++++ src/providers/trait.rs | 199 +++ src/providers/webui.rs | 1247 ++++++++++++++ src/selftest/mod.rs | 79 + src/selftest/runner.rs | 271 +++ src/server/error.rs | 88 + src/server/logbuf.rs | 141 ++ src/server/middleware.rs | 69 + src/server/mod.rs | 53 + src/server/models.rs | 449 +++++ src/server/routes.rs | 1137 +++++++++++++ src/server/sse.rs | 404 +++++ src/server/state.rs | 107 ++ src/tokens.rs | 101 ++ src/util.rs | 100 ++ tests/api_integration.rs | 764 +++++++++ tests/e2e_browser.rs | 898 ++++++++++ tests/fixtures/chatgpt_mock.html | 387 +++++ 58 files changed, 19914 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs create mode 100644 config.toml.example create mode 100644 demo.ps1 create mode 100644 demo.sh create mode 100644 providers/chatgpt.toml create mode 100644 providers/claude.toml create mode 100644 providers/deepseek.toml create mode 100644 providers/selectors.md create mode 100644 scripts/ci.ps1 create mode 100644 scripts/e2e.ps1 create mode 100644 src/browser/cdp.rs create mode 100644 src/browser/debug.rs create mode 100644 src/browser/dom.rs create mode 100644 src/browser/engine.rs create mode 100644 src/browser/js.rs create mode 100644 src/browser/mod.rs create mode 100644 src/browser/pool.rs create mode 100644 src/browser/session.rs create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/conversation/fingerprint.rs create mode 100644 src/conversation/mod.rs create mode 100644 src/conversation/store.rs create mode 100644 src/dashboard/index.html create mode 100644 src/dashboard/mod.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/markdown.rs create mode 100644 src/paths.rs create mode 100644 src/providers/backend.rs create mode 100644 src/providers/config.rs create mode 100644 src/providers/mock.rs create mode 100644 src/providers/mod.rs create mode 100644 src/providers/model.rs create mode 100644 src/providers/registry.rs create mode 100644 src/providers/trait.rs create mode 100644 src/providers/webui.rs create mode 100644 src/selftest/mod.rs create mode 100644 src/selftest/runner.rs create mode 100644 src/server/error.rs create mode 100644 src/server/logbuf.rs create mode 100644 src/server/middleware.rs create mode 100644 src/server/mod.rs create mode 100644 src/server/models.rs create mode 100644 src/server/routes.rs create mode 100644 src/server/sse.rs create mode 100644 src/server/state.rs create mode 100644 src/tokens.rs create mode 100644 src/util.rs create mode 100644 tests/api_integration.rs create mode 100644 tests/e2e_browser.rs create mode 100644 tests/fixtures/chatgpt_mock.html 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

+ + + +
+ + +