diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbdc05651..a37b8b972 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: swatinem/rust-cache@v2 with: - workspaces: "./src-tauri -> target" + workspaces: ". -> target" - name: Install Linux dependencies run: | @@ -59,12 +59,10 @@ jobs: run: bun run bundle:plugins - name: Cargo fmt - working-directory: src-tauri run: cargo fmt --all -- --check - name: Cargo test - working-directory: src-tauri - run: cargo test --locked + run: cargo test --workspace --locked linux-package: name: Linux Tauri package build @@ -80,7 +78,7 @@ jobs: targets: x86_64-unknown-linux-gnu - uses: swatinem/rust-cache@v2 with: - workspaces: "./src-tauri -> target" + workspaces: ". -> target" - name: Install Linux dependencies run: | @@ -102,21 +100,25 @@ jobs: - run: bun install --frozen-lockfile - name: Build Linux packages + env: + # linuxdeploy's bundled strip can fail on newer distro libraries with + # `.relr.dyn` sections. Packaging does not require stripping here. + NO_STRIP: "true" run: | bun tauri build --ci --no-sign --bundles deb,rpm,appimage \ --config '{"bundle":{"createUpdaterArtifacts":false}}' - name: Verify Linux package artifacts run: | - test -n "$(find src-tauri/target/release/bundle/deb -maxdepth 1 -name '*.deb' -print -quit)" - test -n "$(find src-tauri/target/release/bundle/rpm -maxdepth 1 -name '*.rpm' -print -quit)" - test -n "$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)" + test -n "$(find target/release/bundle/deb -maxdepth 1 -name '*.deb' -print -quit)" + test -n "$(find target/release/bundle/rpm -maxdepth 1 -name '*.rpm' -print -quit)" + test -n "$(find target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit)" - name: Inspect Linux packages run: | - DEB=$(find src-tauri/target/release/bundle/deb -maxdepth 1 -name '*.deb' -print -quit) - RPM=$(find src-tauri/target/release/bundle/rpm -maxdepth 1 -name '*.rpm' -print -quit) - APPIMAGE=$(find src-tauri/target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit) + DEB=$(find target/release/bundle/deb -maxdepth 1 -name '*.deb' -print -quit) + RPM=$(find target/release/bundle/rpm -maxdepth 1 -name '*.rpm' -print -quit) + APPIMAGE=$(find target/release/bundle/appimage -maxdepth 1 -name '*.AppImage' -print -quit) dpkg-deb --info "$DEB" dpkg-deb --contents "$DEB" | grep -E 'OpenUsage|openusage|\.desktop' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cc57699e3..9e6c72e79 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -119,7 +119,7 @@ jobs: - name: Verify macOS app bundle if: runner.os == 'macOS' run: | - APP=$(find src-tauri/target -path '*/release/bundle/macos/OpenUsage.app' -print -quit) + APP=$(find target -path '*/release/bundle/macos/OpenUsage.app' -print -quit) if [[ -z "$APP" ]]; then echo "Missing macOS app bundle." exit 1 diff --git a/.github/workflows/windows-gui.yml b/.github/workflows/windows-gui.yml new file mode 100644 index 000000000..f9f5e44bd --- /dev/null +++ b/.github/workflows/windows-gui.yml @@ -0,0 +1,61 @@ +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Build the Windows Tauri GUI (NSIS installer + openusage.exe) and upload artifacts. +# Use this to get fresh .exe builds without tagging a release. +# +# After push: GitHub → Actions → "Windows GUI (Tauri)" → Run workflow. +# Download the artifact zip from the completed run and copy into your VM. +name: Windows GUI (Tauri) + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - uses: swatinem/rust-cache@v2 + with: + shared-key: openusage-windows-gui + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install JS deps + run: bun install + + - name: Build Tauri (Windows GUI + NSIS) + shell: bash + run: | + bun run tauri build --ci --no-sign \ + --config '{"bundle":{"createUpdaterArtifacts":false}}' + + - name: Build portable Windows zip + shell: pwsh + run: ./scripts/build-gui-portable-windows.ps1 + + - name: Upload Windows GUI artifacts + uses: actions/upload-artifact@v4 + with: + name: openusage-windows-gui-${{ github.sha }} + retention-days: 21 + path: | + target/release/bundle/nsis/*.exe + target/release/openusage.exe + target/release/openusage-cli.exe + target/release/WebView2Loader.dll + target/*/release/openusage.exe + target/*/release/openusage-cli.exe + target/*/release/WebView2Loader.dll + src-tauri/binaries/openusage-cli-*.exe + openusage_*_windows_*.zip diff --git a/.gitignore b/.gitignore index 3ae7f0a65..b1124cb49 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,12 @@ plans/* package-lock.json # Build artifacts (contents generated by CI, keep folder for Tauri) +target/ +release-artifacts/ +openusage_*.zip +openusage_*.exe +openusage-cli_*.zip +openusage-cli_*.tar.gz +src-tauri/binaries/ src-tauri/resources/bundled_plugins/* !src-tauri/resources/bundled_plugins/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 85fb4a3d4..b4e1240fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ ## Before Creating Pull Request - Before creating a PR or pushing to main, ensure that `README.md` is updated with what plugins are supported. -- On any plugin change/new plugin, audit plugin-exposed request/response fields against `src-tauri/src/plugin_engine/host_api.rs` redaction lists and add/update tests for gaps. Compare with existing plugins for patterns. +- On any plugin change/new plugin, audit plugin-exposed request/response fields against `crates/openusage-core/src/plugin_engine/host_api.rs` redaction lists and add/update tests for gaps. Compare with existing plugins for patterns. - In `plugin.json`, set `brandColor` to the provider's real brand color. - Plugin SVG logos must use `currentColor` so icon theming works correctly. - If the PR includes visual changes, refuse to create it without providing before/after screenshots. diff --git a/src-tauri/Cargo.lock b/Cargo.lock similarity index 75% rename from src-tauri/Cargo.lock rename to Cargo.lock index c9264dd44..8064aa7bd 100644 --- a/src-tauri/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -54,6 +63,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -71,9 +92,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -97,7 +118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -110,11 +131,61 @@ dependencies = [ "libc", ] +[[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.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -127,9 +198,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-broadcast" @@ -182,7 +253,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -213,7 +284,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -224,14 +295,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -239,7 +310,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -259,7 +330,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -304,15 +375,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -320,14 +391,30 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", ] [[package]] @@ -348,7 +435,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools", @@ -358,8 +445,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn 2.0.117", + "shlex 1.3.0", + "syn 2.0.118", ] [[package]] @@ -385,18 +472,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -415,20 +502,29 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -446,32 +542,33 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -480,25 +577,34 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byte-unit" -version = "5.2.0" +version = "5.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" dependencies = [ "rust_decimal", "schemars 1.2.1", @@ -528,6 +634,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -548,9 +660,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -561,7 +673,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -582,9 +694,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" dependencies = [ "serde_core", ] @@ -622,16 +734,31 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" -version = "1.2.56" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -682,15 +809,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -715,15 +855,88 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -734,6 +947,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -751,15 +978,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "convert_case" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" dependencies = [ "unicode-segmentation", ] @@ -806,7 +1027,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -819,7 +1040,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "libc", ] @@ -853,18 +1074,62 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] [[package]] name = "crypto-common" @@ -886,23 +1151,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - [[package]] name = "cssparser" version = "0.36.0" @@ -912,7 +1160,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -923,7 +1171,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", ] [[package]] @@ -953,9 +1222,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -963,34 +1232,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -998,58 +1266,85 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.5.8" +name = "dbus-secret-service" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" dependencies = [ - "powerfmt", - "serde_core", + "dbus", + "zeroize", ] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "bitflags 1.3.2", + "defmt-macros", ] [[package]] -name = "derive_more" -version = "0.99.20" +name = "defmt-macros" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ - "convert_case 0.4.0", + "defmt-parser", "proc-macro2", "quote", - "rustc_version", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] -name = "derive_more" -version = "2.1.1" +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "derive_more-impl", + "thiserror 2.0.18", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", ] [[package]] @@ -1068,7 +1363,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", ] @@ -1082,6 +1377,15 @@ dependencies = [ "dirs-sys 0.3.7", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1102,6 +1406,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -1120,21 +1436,21 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", - "block2", + "bitflags 2.13.0", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1157,7 +1473,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1167,12 +1483,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser 0.36.0", + "cssparser", "foldhash 0.2.0", - "html5ever 0.38.0", + "html5ever", "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", + "selectors", + "tendril", ] [[package]] @@ -1228,20 +1544,20 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embed-resource" -version = "3.0.6" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "vswhom", "winreg 0.55.0", ] @@ -1285,7 +1601,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1298,6 +1614,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter 2.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1346,11 +1685,33 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -1382,13 +1743,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1453,7 +1813,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1489,16 +1849,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -1568,7 +1918,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1600,15 +1950,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "gdk" version = "0.18.2" @@ -1724,21 +2065,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1748,7 +2078,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] @@ -1759,24 +2089,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1789,6 +2118,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -1827,7 +2162,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -1855,7 +2190,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1876,14 +2211,14 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "global-hotkey" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "once_cell", "serde", "thiserror 2.0.18", @@ -1952,14 +2287,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1967,7 +2302,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1980,7 +2315,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -1989,20 +2333,31 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2027,18 +2382,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - [[package]] name = "html5ever" version = "0.38.0" @@ -2046,14 +2389,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever 0.38.0", + "markup5ever", ] [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2090,18 +2433,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2113,7 +2456,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2121,15 +2463,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2212,12 +2553,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2225,9 +2567,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2238,9 +2580,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2252,15 +2594,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -2272,15 +2614,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -2291,12 +2633,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2316,9 +2652,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2326,9 +2662,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -2337,6 +2673,31 @@ dependencies = [ "png 0.18.1", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "1.9.3" @@ -2350,16 +2711,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -2379,20 +2749,23 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "instability" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "iri-string" -version = "0.7.10" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-docker" @@ -2413,6 +2786,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2424,9 +2803,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "javascriptcore-rs" @@ -2451,6 +2830,31 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "jni" version = "0.21.1" @@ -2460,36 +2864,89 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2521,28 +2978,31 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] [[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" +name = "keyring" +version = "3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.13.0", - "selectors 0.24.0", -] + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libappindicator" @@ -2605,16 +3065,30 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.11.0", "libc", - "plain", - "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2623,9 +3097,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2638,37 +3112,40 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" dependencies = [ "value-bag", ] [[package]] -name = "lru-slab" -version = "0.1.2" +name = "lru" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] -name = "mac" -version = "0.1.1" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "markup5ever" -version = "0.14.1" +name = "mac-notification-sys" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" dependencies = [ + "cc", "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "time", + "uuid", ] [[package]] @@ -2678,32 +3155,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" dependencies = [ "log", - "tendril 0.5.0", + "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2744,20 +3204,21 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "log", + "wasi", "windows-sys 0.61.2", ] [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -2765,18 +3226,18 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.1" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" dependencies = [ "crossbeam-channel", "dpi", "gtk", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -2796,7 +3257,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -2807,8 +3268,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", - "jni-sys", + "bitflags 2.13.0", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -2822,7 +3283,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -2833,22 +3294,16 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", ] -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - [[package]] name = "nom" version = "7.1.3" @@ -2859,11 +3314,34 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-traits" @@ -2876,9 +3354,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2886,14 +3364,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2905,6 +3383,22 @@ dependencies = [ "libc", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2915,25 +3409,41 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", - "block2", + "bitflags 2.13.0", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", "objc2-cloud-kit", - "objc2-core-data", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.2", "objc2-core-text", "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -2942,9 +3452,21 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-foundation", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2953,9 +3475,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-foundation", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -2964,9 +3486,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -2975,21 +3497,33 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "dispatch2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-core-image" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -2998,8 +3532,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -3008,8 +3542,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.0", - "objc2", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", ] @@ -3020,8 +3554,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.11.0", - "objc2", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", @@ -3042,16 +3576,28 @@ dependencies = [ "cc", ] +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", - "block2", + "bitflags 2.13.0", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -3061,8 +3607,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.0", - "objc2", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -3072,20 +3618,45 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" dependencies = [ - "objc2", + "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-osa-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.0", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", ] [[package]] @@ -3094,10 +3665,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.0", - "objc2", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -3106,8 +3677,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.11.0", - "objc2", + "bitflags 2.13.0", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -3117,18 +3688,18 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", "objc2-cloud-kit", - "objc2-core-data", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image", + "objc2-core-image 0.3.2", "objc2-core-location", "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "objc2-user-notifications", ] @@ -3138,8 +3709,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -3148,21 +3719,36 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", - "block2", - "objc2", - "objc2-app-kit", + "bitflags 2.13.0", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-javascript-core", "objc2-security", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" -version = "1.21.3" +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 = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opaque-debug" @@ -3172,27 +3758,25 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.3" +version = "5.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3205,7 +3789,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3216,9 +3800,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -3230,24 +3814,20 @@ dependencies = [ name = "openusage" version = "0.6.37" dependencies = [ - "aes-gcm", - "base64 0.22.1", "dirs 6.0.0", "gtk", "libc", "log", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-web-kit", - "regex-lite", - "reqwest 0.13.3", - "rquickjs", + "openusage-core", + "reqwest 0.13.4", "rust_xlsxwriter", "serde", "serde_json", "serial_test", - "sha2 0.11.0", "tauri", "tauri-build", "tauri-nspanel", @@ -3257,15 +3837,79 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-positioner", "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", "time", "tokio", "uuid", + "window-vibrancy 0.5.3", "zip 8.6.0", ] +[[package]] +name = "openusage-cli" +version = "0.6.37" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "clap", + "color-eyre", + "crossterm", + "csv", + "dirs 5.0.1", + "env_logger", + "include_dir", + "libc", + "log", + "notify-rust", + "openusage-core", + "owo-colors", + "ratatui", + "reqwest 0.13.4", + "rusqlite", + "serde", + "serde_json", + "signal-hook", + "sysinfo", + "tabled", + "textwrap", + "tokio", + "toml 0.8.2", +] + +[[package]] +name = "openusage-core" +version = "0.6.37" +dependencies = [ + "aes-gcm", + "anyhow", + "base64 0.22.1", + "chrono", + "csv", + "dirs 6.0.0", + "keyring", + "libc", + "log", + "regex-lite", + "reqwest 0.13.4", + "rquickjs", + "rusqlite", + "serde", + "serde_json", + "serial_test", + "sha2 0.11.0", + "tempfile", + "time", + "users", +] + +[[package]] +name = "openusage-win-launcher" +version = "0.1.0" + [[package]] name = "option-ext" version = "0.2.0" @@ -3284,15 +3928,15 @@ dependencies = [ [[package]] name = "os_info" -version = "3.14.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" dependencies = [ "android_system_properties", "log", "nix", - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "objc2-ui-kit", "serde", "windows-sys 0.61.2", @@ -3304,14 +3948,20 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "objc2-osa-kit", "serde", "serde_json", "thiserror 2.0.18", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "pango" version = "0.18.3" @@ -3337,6 +3987,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "papergrid" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b0f8def1f117e13c895f3eda65a7b5650688da29d6ad04635f61bc7b92eebd" +dependencies = [ + "bytecount", + "fnv", + "unicode-width 0.2.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3361,22 +4022,22 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] [[package]] -name = "pastey" -version = "0.2.1" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pathdiff" +name = "pastey" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "percent-encoding" @@ -3384,104 +4045,25 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator", + "phf_shared", ] [[package]] @@ -3491,21 +4073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", + "phf_shared", ] [[package]] @@ -3514,38 +4082,11 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", + "syn 2.0.118", ] [[package]] @@ -3554,7 +4095,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher", ] [[package]] @@ -3563,12 +4104,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -3582,24 +4117,18 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "quick-xml", "serde", "time", @@ -3624,7 +4153,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -3641,7 +4170,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3657,11 +4186,26 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3694,7 +4238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3723,7 +4267,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3751,10 +4295,26 @@ dependencies = [ ] [[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "proc-macro2" @@ -3787,24 +4347,24 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.28" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3822,15 +4382,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -3844,23 +4405,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3885,23 +4446,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.7.3" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3910,22 +4457,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3948,15 +4496,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -3976,21 +4515,39 @@ dependencies = [ ] [[package]] -name = "rand_hc" -version = "0.2.0" +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 = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.10.1", ] [[package]] -name = "rand_pcg" -version = "0.2.1" +name = "ratatui" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "rand_core 0.5.1", + "bitflags 2.13.0", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", ] [[package]] @@ -4000,21 +4557,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "bitflags 2.11.0", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", ] [[package]] @@ -4056,14 +4624,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -4073,9 +4641,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -4090,9 +4658,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -4154,9 +4722,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4202,17 +4770,17 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" dependencies = [ - "block2", + "block2 0.6.2", "dispatch2", "glib-sys", "gobject-sys", "gtk-sys", "js-sys", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -4265,9 +4833,9 @@ dependencies = [ [[package]] name = "rquickjs" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a" +checksum = "7ce6b626d30ecbedaaf8097a04982bc3b081f958f5bdf9b8796be4ac00ee48bb" dependencies = [ "rquickjs-core", "rquickjs-macro", @@ -4275,56 +4843,71 @@ dependencies = [ [[package]] name = "rquickjs-core" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" +checksum = "d9334dd11023d5ae6c751f64496510a576208802ed1d022ef94afa7639c2c55e" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", "relative-path", "rquickjs-sys", ] [[package]] name = "rquickjs-macro" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80" +checksum = "7204086521740ca03f33036579801871aa5b714a0e7f68b4c2e98d68b0380bfb" dependencies = [ - "convert_case 0.10.0", + "convert_case", "fnv", "ident_case", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro-crate 3.5.0", "proc-macro2", "quote", "rquickjs-core", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "rquickjs-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83" +checksum = "eef73520804cf5aa4876097ac5733058d628c769eba9678e0f4dba5a4ef79703" dependencies = [ "bindgen", "cc", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rkyv", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -4336,11 +4919,17 @@ dependencies = [ "zip 7.2.0", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4351,24 +4940,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -4381,21 +4983,21 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4403,20 +5005,20 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4430,9 +5032,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4442,9 +5044,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4461,20 +5063,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -4527,7 +5120,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4536,25 +5129,32 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4571,48 +5171,30 @@ dependencies = [ "libc", ] -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - [[package]] name = "selectors" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.0", - "cssparser 0.36.0", - "derive_more 2.1.1", + "bitflags 2.13.0", + "cssparser", + "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", + "phf", + "phf_codegen", "precomputed-hash", "rustc-hash", - "servo_arc 0.4.3", + "servo_arc", "smallvec", ] [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -4657,7 +5239,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4668,7 +5250,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4692,7 +5274,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4727,15 +5309,16 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -4746,40 +5329,39 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4801,17 +5383,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", + "syn 2.0.118", ] [[package]] @@ -4845,12 +5417,48 @@ dependencies = [ "digest 0.11.3", ] +[[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 = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4863,27 +5471,31 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] -name = "simdutf8" -version = "0.1.5" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] -name = "siphasher" -version = "0.3.11" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -4893,15 +5505,21 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smawk" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4916,13 +5534,13 @@ dependencies = [ "bytemuck", "js-sys", "ndk", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -4962,17 +5580,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "string_cache" -version = "0.8.9" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_cache" @@ -4982,30 +5593,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -5016,6 +5615,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.118", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5046,9 +5667,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5072,7 +5693,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5084,13 +5705,27 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5118,14 +5753,37 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tabled" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6709222f3973137427ce50559cd564dc187a95b9cfe01613d2f4e93610e510a" +dependencies = [ + "papergrid", + "tabled_derive", +] + +[[package]] +name = "tabled_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "931be476627d4c54070a1f3a9739ccbfec9b36b39815106a20cce2243bbcefe1" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "tao" -version = "0.35.0" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cf65722394c2ac443e80120064987f8914ee1d4e4e36e63cdf10f2990f01159" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.0", - "block2", + "bitflags 2.13.0", + "block2 0.6.2", "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", @@ -5136,14 +5794,14 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-ui-kit", "once_cell", "parking_lot", @@ -5152,7 +5810,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5166,7 +5824,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5177,9 +5835,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.44" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5194,9 +5852,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" dependencies = [ "anyhow", "bytes", @@ -5210,20 +5868,20 @@ dependencies = [ "heck 0.5.0", "http", "image", - "jni", + "jni 0.21.1", "libc", "log", "mime", "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -5240,15 +5898,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy", - "windows", + "window-vibrancy 0.6.0", + "windows 0.61.3", ] [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" dependencies = [ "anyhow", "cargo_toml", @@ -5267,9 +5925,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" dependencies = [ "base64 0.22.1", "brotli", @@ -5283,7 +5941,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "syn 2.0.117", + "syn 2.0.118", "tauri-utils", "thiserror 2.0.18", "time", @@ -5294,14 +5952,14 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "tauri-codegen", "tauri-utils", ] @@ -5311,18 +5969,18 @@ name = "tauri-nspanel" version = "2.1.0" source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#a3122e894383aa068ec5365a42994e3ac94ba1b6" dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "pastey", "tauri", ] [[package]] name = "tauri-plugin" -version = "2.5.4" +version = "2.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" dependencies = [ "anyhow", "glob", @@ -5331,7 +5989,6 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "toml 0.9.12+spec-1.1.0", "walkdir", ] @@ -5343,7 +6000,7 @@ dependencies = [ "futures", "log", "os_info", - "rand 0.9.2", + "rand 0.9.4", "reqwest 0.12.28", "serde", "serde_json", @@ -5396,7 +6053,7 @@ dependencies = [ "dunce", "glob", "log", - "objc2-foundation", + "objc2-foundation 0.3.2", "percent-encoding", "schemars 0.8.22", "serde", @@ -5412,9 +6069,9 @@ dependencies = [ [[package]] name = "tauri-plugin-global-shortcut" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" dependencies = [ "global-hotkey", "log", @@ -5435,8 +6092,8 @@ dependencies = [ "byte-unit", "fern", "log", - "objc2", - "objc2-foundation", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "serde", "serde_json", "serde_repr", @@ -5455,8 +6112,8 @@ checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "open", "schemars 0.8.22", "serde", @@ -5465,10 +6122,25 @@ dependencies = [ "tauri-plugin", "thiserror 2.0.18", "url", - "windows", + "windows 0.61.3", "zbus", ] +[[package]] +name = "tauri-plugin-positioner" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686204dc3171a2d59436e470c8ea99f08c5f5bf63ef1a40f900d6d48f433b816" +dependencies = [ + "log", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -5511,7 +6183,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -5530,16 +6202,16 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" dependencies = [ "cookie", "dpi", "gtk", "http", - "jni", - "objc2", + "jni 0.21.1", + "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -5550,21 +6222,21 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -5575,15 +6247,15 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" dependencies = [ "anyhow", "brotli", @@ -5592,14 +6264,12 @@ dependencies = [ "dom_query", "dunce", "glob", - "html5ever 0.29.1", "http", "infer", "json-patch", - "kuchikiki", "log", "memchr", - "phf 0.13.1", + "phf", "plist", "proc-macro2", "quote", @@ -5621,47 +6291,57 @@ dependencies = [ [[package]] name = "tauri-winres" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", ] [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "futf", - "mac", - "utf-8", + "new_debug_unreachable", ] [[package]] -name = "tendril" -version = "0.5.0" +name = "textwrap" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ - "new_debug_unreachable", - "utf-8", + "smawk", + "unicode-linebreak", + "unicode-width 0.2.2", ] [[package]] @@ -5690,7 +6370,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5701,17 +6381,25 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -5723,15 +6411,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -5739,9 +6427,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5749,9 +6437,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5785,7 +6473,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5839,7 +6527,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", @@ -5854,7 +6542,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -5896,7 +6584,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -5907,7 +6595,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -5916,14 +6604,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.15", + "winnow 1.0.3", ] [[package]] @@ -5958,20 +6646,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6001,37 +6689,59 @@ dependencies = [ name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "tracing", + "tracing-subscriber", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "once_cell", + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" dependencies = [ "crossbeam-channel", "dirs 6.0.0", "libappindicator", "muda", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", @@ -6059,15 +6769,15 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -6121,17 +6831,40 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "universal-hash" @@ -6175,16 +6908,20 @@ dependencies = [ ] [[package]] -name = "utf-8" -version = "0.7.6" +name = "users" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" +dependencies = [ + "libc", + "log", +] [[package]] name = "utf8-width" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" [[package]] name = "utf8_iter" @@ -6192,18 +6929,30 @@ 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.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "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 = "value-bag" version = "1.12.0" @@ -6267,12 +7016,6 @@ dependencies = [ "try-lock", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6281,27 +7024,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6312,23 +7046,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6336,48 +7066,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.13.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -6391,23 +7099,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap 2.13.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6425,14 +7121,14 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", ] [[package]] @@ -6481,9 +7177,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -6496,10 +7192,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", ] [[package]] @@ -6510,7 +7206,7 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6520,7 +7216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -6555,21 +7251,45 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-vibrancy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831ad7678290beae36be6f9fad9234139c7f00f3b536347de7745621716be82d" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + [[package]] name = "window-vibrancy" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", ] +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -6592,14 +7312,26 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -6611,8 +7343,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -6629,6 +7361,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6637,7 +7380,18 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -6648,7 +7402,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6684,6 +7438,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6729,6 +7492,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6780,6 +7552,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6837,6 +7624,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6855,6 +7648,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6873,6 +7672,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6903,6 +7708,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6921,6 +7732,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6939,6 +7756,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6957,6 +7780,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6983,15 +7812,15 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "winreg" @@ -7014,106 +7843,24 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.13.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap 2.13.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.13.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wry" -version = "0.55.0" +version = "0.55.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3013fd6116aac351dd2e18f349b28b2cfef3a5ff3253a9d0ce2d7193bb1b4429" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" dependencies = [ "base64 0.22.1", - "block2", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs 6.0.0", @@ -7124,13 +7871,13 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", @@ -7144,7 +7891,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -7187,7 +7934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -7204,7 +7951,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -7215,9 +7962,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7226,21 +7973,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zbus" -version = "5.14.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" dependencies = [ "async-broadcast", "async-executor", @@ -7258,14 +8005,14 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.15", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -7273,14 +8020,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zbus_names", "zvariant", "zvariant_utils", @@ -7288,67 +8035,81 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" dependencies = [ "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -7357,9 +8118,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -7368,13 +8129,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7385,7 +8146,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", ] @@ -7397,7 +8158,7 @@ checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", "typed-path", "zopfli", @@ -7411,7 +8172,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.0", "memchr", "typed-path", "zopfli", @@ -7419,9 +8180,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" @@ -7443,40 +8204,40 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.10.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 0.7.15", + "syn 2.0.118", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..707cb0437 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +resolver = "2" +members = [ + "crates/openusage-core", + "crates/openusage-cli", + "crates/openusage-win-launcher", + "src-tauri", +] diff --git a/LICENSE b/LICENSE index c23d6adb5..1fdac61fd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,9 @@ MIT License Copyright (c) 2026 Robin Ebers +Copyright (c) 2026 OpenUsage Community contributors +Windows support includes code adapted from barramee27/crossusage contributors: +https://github.com/barramee27/crossusage Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 11d100d0e..ed86ae685 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ OpenUsage Community is an independent, community-maintained continuation of the original [OpenUsage](https://github.com/robinebers/openusage) project. -The goal of this fork is to continue the cross-platform Tauri-based direction with official Linux support, best-effort macOS support, and room for future Windows support. +The goal of this fork is to continue the cross-platform Tauri-based direction with official Linux support, Windows support, best-effort macOS support, and simple provider maintenance. See your usage at a glance from your menu bar or system tray. No digging through dashboards. @@ -21,6 +21,7 @@ See your usage at a glance from your menu bar or system tray. No digging through OpenUsage Community is currently focused on: * Official Linux support +* Windows installer and portable builds * Stable AppImage, `.deb`, and `.rpm` releases * System tray behavior across different Linux desktop environments * Best-effort unsigned macOS builds through Homebrew @@ -45,6 +46,9 @@ Available builds: * Linux AppImage * Linux `.deb` * Linux `.rpm` +* Windows NSIS installer +* Windows portable GUI zip +* Windows CLI zip The app auto-updates where supported. Install once and you're set. @@ -69,6 +73,32 @@ On Wayland, panel positioning is best-effort and depends on the compositor. The Reading credentials stored in the system keyring requires `secret-tool`, usually provided by the `libsecret` or `libsecret-tools` package. +## Install on Windows + +Download the Windows installer or portable zip from the [latest release](https://github.com/openusage-community/openusage/releases/latest). + +Installer: + +```powershell +irm https://raw.githubusercontent.com/openusage-community/openusage/main/scripts/install.ps1 | iex +``` + +Portable GUI zip layout: + +* `openusage.exe` +* `openusage_gui.exe` +* `openusage-cli.exe` +* `WebView2Loader.dll` +* `resources/` +* `README-Windows.txt` + +CLI-only zip layout: + +* `openusage-cli.exe` +* `resources/bundled_plugins/` + +Set `OPENUSAGE_RESOURCES` when running `openusage-cli` from a custom layout. Point it at a folder containing either `bundled_plugins/` or `resources/bundled_plugins/`. + ## Install on Linux Grab the asset for your distro from the [latest release](https://github.com/openusage-community/openusage/releases/latest). @@ -187,7 +217,7 @@ Keep changes focused. Avoid feature creep. Do not submit AI-generated code witho OpenUsage Community is based on the original [OpenUsage](https://github.com/robinebers/openusage) project. -This fork is independent and community-maintained. It continues the Tauri-based cross-platform direction, with Linux as the primary focus. +This fork is independent and community-maintained. It continues the Tauri-based cross-platform direction across Linux, Windows, and best-effort macOS. The original project, its name, and its prior work are credited to the original author and contributors. @@ -195,6 +225,8 @@ The original project, its name, and its prior work are credited to the original Based on the original [OpenUsage](https://github.com/robinebers/openusage) project by [Robin Ebers](https://github.com/robinebers). +Windows support is adapted from [barramee27/crossusage](https://github.com/barramee27/crossusage) under the MIT license. + Inspired by [CodexBar](https://github.com/steipete/CodexBar) by [@steipete](https://github.com/steipete). Thanks to all original OpenUsage contributors and everyone helping improve cross-platform support. @@ -239,4 +271,25 @@ OpenUsage Community is built with: * TypeScript * WebKitGTK on Linux +### Build + +```sh +bun install +bun run bundle:plugins +cargo test --workspace +bun tauri build +``` + +The Rust code is a workspace with `src-tauri`, `crates/openusage-core`, `crates/openusage-cli`, and `crates/openusage-win-launcher`. + +Windows artifacts: + +```sh +bun tauri build --target x86_64-pc-windows-gnu +bun run release:gui-portable-zip-windows-gnu +bun run release:cli-zip-windows-gnu +``` + +If local Windows cross-build dependencies are not installed, use the manual **Windows GUI (Tauri)** GitHub Actions workflow. + diff --git a/crates/openusage-cli/Cargo.toml b/crates/openusage-cli/Cargo.toml new file mode 100644 index 000000000..c3e790063 --- /dev/null +++ b/crates/openusage-cli/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "openusage-cli" +version = "0.6.37" +description = "OpenUsage CLI — terminal interface for the same plugin engine as the GUI" +edition = "2021" + +[[bin]] +name = "openusage-cli" +path = "src/main.rs" + +[dependencies] +include_dir = "0.7.4" +anyhow = "1" +chrono = "0.4" +clap = { version = "4", features = ["derive", "cargo"] } +color-eyre = "0.6" +crossterm = "0.28" +openusage-core = { path = "../openusage-core" } +dirs = "5" +env_logger = "0.11" +log = "0.4" +notify-rust = "4" +owo-colors = "4" +ratatui = "0.28" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.22" +csv = "1.3" +reqwest = { version = "0.13", features = ["blocking", "json"] } +rusqlite = { version = "0.32", features = ["bundled"] } +signal-hook = "0.3" +sysinfo = "0.33" +textwrap = "0.16" +tabled = { version = "0.17", features = ["std"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +toml = "0.8" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/openusage-cli/build.rs b/crates/openusage-cli/build.rs new file mode 100644 index 000000000..3c6eac462 --- /dev/null +++ b/crates/openusage-cli/build.rs @@ -0,0 +1,26 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +use std::path::Path; + +fn emit_rerun(dir: &Path) { + if !dir.is_dir() { + return; + } + println!("cargo:rerun-if-changed={}", dir.display()); + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for ent in rd.flatten() { + let p = ent.path(); + if p.is_dir() { + emit_rerun(&p); + } else { + println!("cargo:rerun-if-changed={}", p.display()); + } + } +} + +fn main() { + let manifest = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let plugins = manifest.join("../../plugins"); + emit_rerun(&plugins); +} diff --git a/crates/openusage-cli/src/batch_probe.rs b/crates/openusage-cli/src/batch_probe.rs new file mode 100644 index 000000000..bf4c5548b --- /dev/null +++ b/crates/openusage-cli/src/batch_probe.rs @@ -0,0 +1,113 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Batch CLI (`list` / `probe` / live `export`) uses a wall-clock probe timeout (same default as the TUI). +//! +//! We use **`std::thread` + `mpsc::recv_timeout`** — **not** a per-probe Tokio `Runtime`. Creating and +//! dropping a `Runtime` after `timeout!` can **block forever** on shutdown while a `spawn_blocking` +//! probe is still running, which looked like “stuck on 11/16” after a timeout message. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +use openusage_core::plugin_engine::manifest::LoadedPlugin; +use openusage_core::plugin_engine::runtime::{self, MetricLine, PluginOutput}; + +/// Same as the dashboard: `OPENUSAGE_PROBE_TIMEOUT_SEC` or **120** seconds. +pub fn probe_timeout_secs() -> u64 { + std::env::var("OPENUSAGE_PROBE_TIMEOUT_SEC") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&s| s > 0) + .unwrap_or(120) +} + +fn probe_error_output(plugin: &LoadedPlugin, message: String) -> PluginOutput { + PluginOutput { + provider_id: plugin.manifest.id.clone(), + display_name: plugin.manifest.name.clone(), + plan: None, + warning: None, + lines: vec![MetricLine::Text { + label: "Error".into(), + value: message, + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }], + icon_url: plugin.icon_data_url.clone(), + } +} + +fn exit_if_interrupted(interrupt: Option<&Arc>) { + if let Some(f) = interrupt { + if f.load(Ordering::SeqCst) { + eprintln!("\nopenusage-cli: interrupted"); + std::process::exit(130); + } + } +} + +/// Wall-clock timeout; optional **SIGINT** flag checked every ~200ms (so Ctrl+C works during a probe). +/// +/// On timeout the probe **thread is left running** in the background until it finishes (same as +/// skipping ahead — we do not join it). The process exits after `list` completes, so this is bounded. +pub fn run_probe_with_timeout( + plugin: &LoadedPlugin, + app_data: &PathBuf, + version: &str, + interrupt: Option<&Arc>, +) -> PluginOutput { + let plugin_for_err = plugin.clone(); + let plugin_id = plugin_for_err.manifest.id.clone(); + let timeout_sec = probe_timeout_secs(); + let deadline = Instant::now() + Duration::from_secs(timeout_sec); + + let plugin_thread = plugin.clone(); + let app_data_thread = app_data.clone(); + let version_thread = version.to_string(); + let (tx, rx) = mpsc::channel(); + let _join = thread::spawn(move || { + let out = runtime::run_probe(&plugin_thread, &app_data_thread, &version_thread); + let _ = tx.send(out); + }); + + const TICK: Duration = Duration::from_millis(200); + + loop { + exit_if_interrupted(interrupt); + + let now = Instant::now(); + if now >= deadline { + eprintln!( + "openusage-cli: probe timed out after {timeout_sec}s for provider `{plugin_id}` \ + (set OPENUSAGE_PROBE_TIMEOUT_SEC or e.g. `openusage-cli list cursor`.)" + ); + return probe_error_output( + &plugin_for_err, + format!( + "Probe timed out after {timeout_sec}s. The provider may be waiting on the network — \ + set OPENUSAGE_PROBE_TIMEOUT_SEC or probe fewer providers." + ), + ); + } + + let remaining = deadline.saturating_duration_since(now); + let wait = TICK.min(remaining); + + match rx.recv_timeout(wait) { + Ok(o) => return o, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => { + return probe_error_output( + &plugin_for_err, + "Probe thread ended without returning a result (panic?).".into(), + ); + } + } + } +} diff --git a/crates/openusage-cli/src/cli_width.rs b/crates/openusage-cli/src/cli_width.rs new file mode 100644 index 000000000..d9e3a04eb --- /dev/null +++ b/crates/openusage-cli/src/cli_width.rs @@ -0,0 +1,152 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Terminal width detection and responsive table layout thresholds. + +use std::io::{self, IsTerminal}; + +/// Below this width, `probe --human` uses stacked lines; between this and [WIDTH_FULL_TABLE_AT] it uses a +/// **single-line** compact table with truncation (never multi-line wrap — that breaks ASCII borders). +pub const WIDTH_STACKED_BELOW: u16 = 100; +/// At or above this width, ASCII tables (`list` full grid, `probe --human` full, `usage-stats` full) are used. +/// Kept **high** so typical 80–120 col terminals stay on **stacked** / compact layouts without flags or `$COLUMNS=`. +pub const WIDTH_FULL_TABLE_AT: u16 = 140; +/// Default when not a TTY and `COLUMNS` is unset. +pub const DEFAULT_COLUMNS: u16 = 80; +/// Minimum width used for calculations (avoid degenerate wraps). +pub const MIN_TERM_WIDTH: u16 = 40; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CliTableLayout { + /// Full `Style::rounded()`, no forced cell wrap (wide terminals). + Full, + /// `Style::modern()` + **truncate** long cells (never multi-line wrap). + Compact, + /// One record per block, no wide table. + Stacked, +} + +/// Best-effort terminal width: **minimum** of ioctl (`TIOCGWINSZ`), crossterm, and `$COLUMNS` when set. +/// +/// Using the smallest value avoids picking a too-wide layout when sources disagree (resize lag, IDE quirks). +/// No CLI flags required — shells often set `$COLUMNS` automatically after resize. +pub fn terminal_width() -> u16 { + let mut widths: Vec = Vec::new(); + + #[cfg(unix)] + if let Some(w) = unix_term_cols_ioctl() { + widths.push(w); + } + + if io::stdout().is_terminal() { + if let Ok((w, _)) = crossterm::terminal::size() { + widths.push(w); + } + } + + if let Ok(s) = std::env::var("COLUMNS") { + if let Ok(c) = s.parse::() { + if c >= MIN_TERM_WIDTH { + widths.push(c); + } + } + } + + let w = if widths.is_empty() { + DEFAULT_COLUMNS + } else { + widths.into_iter().min().expect("widths non-empty") + }; + + w.max(MIN_TERM_WIDTH) +} + +/// `TIOCGWINSZ` on stdout and stderr (either may be the controlling TTY when the other is redirected). +#[cfg(unix)] +fn unix_term_cols_ioctl() -> Option { + use libc::{ioctl, winsize, TIOCGWINSZ}; + use std::os::unix::io::AsRawFd; + + let mut best: Option = None; + let stdout = std::io::stdout(); + let stderr = std::io::stderr(); + for fd in [stdout.as_raw_fd(), stderr.as_raw_fd()] { + if fd < 0 { + continue; + } + let col = unsafe { + let mut ws: winsize = std::mem::zeroed(); + if ioctl(fd, TIOCGWINSZ, &mut ws as *mut _) == 0 && ws.ws_col > 0 { + Some(ws.ws_col as u16) + } else { + None + } + }; + if let Some(c) = col { + best = Some(match best { + None => c, + Some(b) => b.min(c), + }); + } + } + best +} + +pub fn layout_for_width(w: u16) -> CliTableLayout { + if w < WIDTH_STACKED_BELOW { + CliTableLayout::Stacked + } else if w < WIDTH_FULL_TABLE_AT { + CliTableLayout::Compact + } else { + CliTableLayout::Full + } +} + +/// `openusage-cli list`: avoid wrapped multi-line cells in ASCII tables — they misalign borders. +/// Use plain stacked blocks below [WIDTH_FULL_TABLE_AT], full table at or above it. +pub fn list_layout_for_width(w: u16) -> CliTableLayout { + if w < WIDTH_FULL_TABLE_AT { + CliTableLayout::Stacked + } else { + CliTableLayout::Full + } +} + +/// Max width for probe `--human` value column when using single-line truncation. +pub fn probe_value_column_wrap_chars(term_w: u16) -> usize { + let w = term_w as usize; + w.saturating_sub(22).max(8).min(100) +} + +/// Word-wrap plain text for stacked `list` / `usage-stats` output. +pub fn wrap_plain(text: &str, width: usize) -> String { + let w = width.max(8); + textwrap::fill(text, w) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_buckets() { + assert_eq!(layout_for_width(40), CliTableLayout::Stacked); + assert_eq!(layout_for_width(99), CliTableLayout::Stacked); + assert_eq!(layout_for_width(100), CliTableLayout::Compact); + assert_eq!(layout_for_width(119), CliTableLayout::Compact); + assert_eq!(layout_for_width(139), CliTableLayout::Compact); + assert_eq!(layout_for_width(140), CliTableLayout::Full); + assert_eq!(layout_for_width(200), CliTableLayout::Full); + } + + #[test] + fn list_and_usage_stats_stack_until_wide_enough_for_table() { + assert_eq!(list_layout_for_width(139), CliTableLayout::Stacked); + assert_eq!(list_layout_for_width(140), CliTableLayout::Full); + } + + #[test] + fn columns_env_fallback() { + // When not in TTY in tests, COLUMNS may or may not be set — just ensure functions return sane values. + let w = terminal_width(); + assert!(w >= MIN_TERM_WIDTH); + } +} diff --git a/crates/openusage-cli/src/config.rs b/crates/openusage-cli/src/config.rs new file mode 100644 index 000000000..6a4abbabc --- /dev/null +++ b/crates/openusage-cli/src/config.rs @@ -0,0 +1,148 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! CLI configuration — `~/.config/openusage/config.toml` (or `--config` path) + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CliConfig { + /// Seconds between data probe cycles (ignored for effective probe when `low_power_mode` forces 10s). + #[serde(default = "default_refresh_sec")] + pub refresh_sec: u64, + /// When true: 10s probes, 2s UI redraws, no animated transitions. + #[serde(default)] + pub low_power_mode: bool, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default = "default_true")] + pub mouse: bool, + /// Left / center / right pane width ratios (sum should be 100). + #[serde(default = "default_pane_ratios")] + pub pane_ratios: [u16; 3], + /// Sparkline / ring buffer depth (per provider) in the dashboard. + #[serde(default = "default_history_capacity")] + pub history_capacity: usize, + /// Append each successful probe snapshot as JSONL under the data-local dir (see `history::history_jsonl_path`). + #[serde(default)] + pub persist_history: bool, +} + +fn default_refresh_sec() -> u64 { + 3 +} + +fn default_theme() -> String { + "dark".into() +} + +fn default_true() -> bool { + true +} + +fn default_pane_ratios() -> [u16; 3] { + [22, 48, 30] +} + +fn default_history_capacity() -> usize { + 120 +} + +impl Default for CliConfig { + fn default() -> Self { + Self { + refresh_sec: default_refresh_sec(), + low_power_mode: false, + theme: default_theme(), + mouse: default_true(), + pane_ratios: default_pane_ratios(), + history_capacity: default_history_capacity(), + persist_history: false, + } + } +} + +impl CliConfig { + pub fn config_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("openusage") + .join("config.toml") + } + + /// Default path or `--config` override. + pub fn resolve_path(override_path: Option) -> PathBuf { + override_path.unwrap_or_else(|| Self::config_path()) + } + + /// Load from default XDG path (see `config_path`). + #[allow(dead_code)] + pub fn load() -> Self { + Self::load_from_path(&Self::config_path()) + } + + pub fn load_from_path(path: &Path) -> Self { + if !path.exists() { + return Self::default(); + } + match std::fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str::(&s).ok()) + { + Some(c) => c, + None => Self::default(), + } + } + + /// Merge session overrides from CLI (non-None fields win). + pub fn merge_cli_overrides( + mut self, + theme: Option<&str>, + refresh_sec: Option, + no_mouse: bool, + ) -> Self { + if let Some(t) = theme { + if !t.is_empty() { + self.theme = t.to_string(); + } + } + if let Some(r) = refresh_sec { + self.refresh_sec = r.max(1); + } + if no_mouse { + self.mouse = false; + } + self + } + + pub fn save_to(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create_dir_all {:?}", parent))?; + } + let s = toml::to_string_pretty(self).context("serialize config")?; + std::fs::write(path, s).with_context(|| format!("write {:?}", path))?; + Ok(()) + } + + #[allow(dead_code)] + pub fn save(&self) -> Result<()> { + self.save_to(&Self::config_path()) + } + + pub fn effective_probe_sec(&self) -> u64 { + if self.low_power_mode { + 10 + } else { + self.refresh_sec.max(1) + } + } + + pub fn ui_tick_ms(&self) -> u64 { + if self.low_power_mode { + 2000 + } else { + 500 + } + } +} diff --git a/crates/openusage-cli/src/cursor_token_usage.rs b/crates/openusage-cli/src/cursor_token_usage.rs new file mode 100644 index 000000000..c82cc208d --- /dev/null +++ b/crates/openusage-cli/src/cursor_token_usage.rs @@ -0,0 +1,364 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! CLI presentation for Cursor CSV usage export (logic in openusage-core). + +use anyhow::{bail, Result}; +use openusage_core::cursor_usage_export::{ + aggregate_by_model, aggregate_by_provider, download_cursor_usage_csv, parse_usage_csv, + resolve_date_range, to_epoch_range_ms, RowAgg, +}; +use serde_json::json; +use std::collections::HashMap; +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +pub use openusage_core::cursor_usage_export::{ + fetch_cursor_month_to_date_totals_for_plugin, format_token_count, +}; + +#[derive(Debug, Clone, Tabled)] +struct SummaryModelRow { + model: String, + #[tabled(rename = "Input")] + input: String, + #[tabled(rename = "Output")] + output: String, + #[tabled(rename = "Cache Write")] + cache_write: String, + #[tabled(rename = "Cache Hit")] + cache_hit: String, + #[tabled(rename = "Total Tokens")] + total_tokens: String, + #[tabled(rename = "Cost (USD)")] + cost_usd: String, +} + +#[derive(Debug, Clone, Tabled)] +struct SummaryProviderRow { + provider: String, + #[tabled(rename = "Input")] + input: String, + #[tabled(rename = "Output")] + output: String, + #[tabled(rename = "Cache Write")] + cache_write: String, + #[tabled(rename = "Cache Hit")] + cache_hit: String, + #[tabled(rename = "Total Tokens")] + total_tokens: String, + #[tabled(rename = "Cost (USD)")] + cost_usd: String, +} + +pub struct UsageStatsArgs { + pub provider: String, + pub since: Option, + pub until: Option, + pub group: String, + pub output: String, + pub json: bool, +} + +pub fn run_usage_stats(args: UsageStatsArgs) -> Result<()> { + if args.provider != "cursor" { + bail!( + "token-level CSV export is only implemented for Cursor (same source as cstats).\n\ + Provider {:?} has no equivalent per-model export in OpenUsage.\n\ + Use `openusage-cli list` / `probe {}` for subscription-style meters.", + args.provider, + args.provider + ); + } + + if args.output != "summary" { + bail!("Only --output summary is implemented (daily mode may be added later)."); + } + + let group = args.group.to_lowercase(); + if group != "model" && group != "provider" { + bail!("--group must be 'model' or 'provider'."); + } + + let (since, until) = resolve_date_range(args.since.as_deref(), args.until.as_deref())?; + let (start_ms, end_ms) = to_epoch_range_ms(&since, &until)?; + + let csv_text = download_cursor_usage_csv(start_ms, end_ms)?; + let rows = parse_usage_csv(&csv_text, &since, &until)?; + + if rows.is_empty() { + println!("No Cursor usage rows in range {since}–{until}."); + return Ok(()); + } + + if group == "model" { + let by_model = aggregate_by_model(&rows); + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "since": since, + "until": until, + "group": "model", + "rows": by_model.iter().map(|(m, a)| json!({ + "model": m, + "input": a.input_no_cache, + "output": a.output, + "cacheWrite": a.input_cache_write, + "cacheHit": a.cache_read, + "totalTokens": a.total_tokens, + "costUsd": format!("{:.2}", a.cost_usd), + })).collect::>(), + "totals": totals_json(&by_model), + }))? + ); + return Ok(()); + } + print_summary_model_table(&since, &until, &by_model)?; + } else { + let by_provider = aggregate_by_provider(&rows); + if args.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "since": since, + "until": until, + "group": "provider", + "rows": by_provider.iter().map(|(p, a)| json!({ + "provider": p, + "input": a.input_no_cache, + "output": a.output, + "cacheWrite": a.input_cache_write, + "cacheHit": a.cache_read, + "totalTokens": a.total_tokens, + "costUsd": format!("{:.2}", a.cost_usd), + })).collect::>(), + "totals": totals_json(&by_provider), + }))? + ); + return Ok(()); + } + print_summary_provider_table(&since, &until, &by_provider)?; + } + + Ok(()) +} + +fn totals_json(m: &HashMap) -> serde_json::Value { + let mut t = RowAgg::default(); + for a in m.values() { + t.input_no_cache += a.input_no_cache; + t.input_cache_write += a.input_cache_write; + t.cache_read += a.cache_read; + t.output += a.output; + t.total_tokens += a.total_tokens; + t.cost_usd += a.cost_usd; + } + json!({ + "input": t.input_no_cache, + "output": t.output, + "cacheWrite": t.input_cache_write, + "cacheHit": t.cache_read, + "totalTokens": t.total_tokens, + "costUsd": format!("{:.2}", t.cost_usd), + }) +} + +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(c); + } + out.chars().rev().collect() +} + +fn fmt_display_date(yyyymmdd: &str) -> String { + if yyyymmdd.len() == 8 { + format!( + "{}-{}-{}", + &yyyymmdd[0..4], + &yyyymmdd[4..6], + &yyyymmdd[6..8] + ) + } else { + yyyymmdd.to_string() + } +} + +fn print_summary_model_table( + since: &str, + until: &str, + map: &HashMap, +) -> Result<()> { + let mut total = RowAgg::default(); + let mut items: Vec<(String, RowAgg)> = + map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + items.sort_by(|a, b| { + b.1.cost_usd + .partial_cmp(&a.1.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + + let mut rows: Vec = Vec::new(); + for (model, a) in &items { + total.input_no_cache += a.input_no_cache; + total.input_cache_write += a.input_cache_write; + total.cache_read += a.cache_read; + total.output += a.output; + total.total_tokens += a.total_tokens; + total.cost_usd += a.cost_usd; + rows.push(SummaryModelRow { + model: model.clone(), + input: fmt_num(a.input_no_cache), + output: fmt_num(a.output), + cache_write: fmt_num(a.input_cache_write), + cache_hit: fmt_num(a.cache_read), + total_tokens: fmt_num(a.total_tokens), + cost_usd: format!("${:.2}", a.cost_usd), + }); + } + + rows.push(SummaryModelRow { + model: "Total".into(), + input: fmt_num(total.input_no_cache), + output: fmt_num(total.output), + cache_write: fmt_num(total.input_cache_write), + cache_hit: fmt_num(total.cache_read), + total_tokens: fmt_num(total.total_tokens), + cost_usd: format!("${:.2}", total.cost_usd), + }); + + println!( + "Cursor usage (CSV export) — {} to {} — costs summed from export rows (see cstats).\n", + fmt_display_date(since), + fmt_display_date(until) + ); + render_summary_model_output(&rows)?; + Ok(()) +} + +fn render_summary_model_output(rows: &[SummaryModelRow]) -> Result<()> { + let w = crate::cli_width::terminal_width(); + let tw = (w as usize).saturating_sub(4).max(20); + if w < crate::cli_width::WIDTH_FULL_TABLE_AT { + for r in rows { + if r.model == "Total" { + println!("---"); + let line = format!( + "Total In: {} Out: {} CacheW: {} CacheR: {} Total tok: {} {}", + r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd + ); + for line in crate::cli_width::wrap_plain(&line, tw).lines() { + println!("{line}"); + } + continue; + } + println!("---"); + println!("Model: {}", r.model); + let line = format!( + "In: {} Out: {} CacheW: {} CacheR: {} Total: {} {}", + r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd + ); + for pl in crate::cli_width::wrap_plain(&line, tw).lines() { + println!(" {pl}"); + } + } + println!(); + } else { + let mut table = Table::new(rows); + table.with(Style::rounded()); + println!("{table}"); + } + Ok(()) +} + +fn print_summary_provider_table( + since: &str, + until: &str, + map: &HashMap, +) -> Result<()> { + let mut total = RowAgg::default(); + let mut items: Vec<(String, RowAgg)> = + map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + items.sort_by(|a, b| { + b.1.cost_usd + .partial_cmp(&a.1.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + + let mut rows: Vec = Vec::new(); + for (prov, a) in &items { + total.input_no_cache += a.input_no_cache; + total.input_cache_write += a.input_cache_write; + total.cache_read += a.cache_read; + total.output += a.output; + total.total_tokens += a.total_tokens; + total.cost_usd += a.cost_usd; + rows.push(SummaryProviderRow { + provider: prov.clone(), + input: fmt_num(a.input_no_cache), + output: fmt_num(a.output), + cache_write: fmt_num(a.input_cache_write), + cache_hit: fmt_num(a.cache_read), + total_tokens: fmt_num(a.total_tokens), + cost_usd: format!("${:.2}", a.cost_usd), + }); + } + + rows.push(SummaryProviderRow { + provider: "Total".into(), + input: fmt_num(total.input_no_cache), + output: fmt_num(total.output), + cache_write: fmt_num(total.input_cache_write), + cache_hit: fmt_num(total.cache_read), + total_tokens: fmt_num(total.total_tokens), + cost_usd: format!("${:.2}", total.cost_usd), + }); + + println!( + "Cursor usage by inferred provider — {} to {} — model→provider mapping is heuristic.\n", + fmt_display_date(since), + fmt_display_date(until) + ); + render_summary_provider_output(&rows)?; + Ok(()) +} + +fn render_summary_provider_output(rows: &[SummaryProviderRow]) -> Result<()> { + let w = crate::cli_width::terminal_width(); + let tw = (w as usize).saturating_sub(4).max(20); + if w < crate::cli_width::WIDTH_FULL_TABLE_AT { + for r in rows { + if r.provider == "Total" { + println!("---"); + let line = format!( + "Total In: {} Out: {} CacheW: {} CacheR: {} Total tok: {} {}", + r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd + ); + for line in crate::cli_width::wrap_plain(&line, tw).lines() { + println!("{line}"); + } + continue; + } + println!("---"); + println!("Provider: {}", r.provider); + let line = format!( + "In: {} Out: {} CacheW: {} CacheR: {} Total: {} {}", + r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd + ); + for pl in crate::cli_width::wrap_plain(&line, tw).lines() { + println!(" {pl}"); + } + } + println!(); + } else { + let mut table = Table::new(rows); + table.with(Style::rounded()); + println!("{table}"); + } + Ok(()) +} diff --git a/crates/openusage-cli/src/daemon.rs b/crates/openusage-cli/src/daemon.rs new file mode 100644 index 000000000..32a30b233 --- /dev/null +++ b/crates/openusage-cli/src/daemon.rs @@ -0,0 +1,196 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Background polling + desktop notifications when usage crosses a threshold. +//! No tray icon — terminal-focused binary only. + +use anyhow::{Context, Result}; +use notify_rust::Notification; +use openusage_core::plugin_engine::manifest::LoadedPlugin; +use openusage_core::plugin_engine::runtime; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::tui::view_model::NormalizedMetricsMapper; + +/// Forwarded to the detached child process. +pub struct SpawnArgs { + pub interval_sec: u64, + pub threshold_percent: f64, + pub cooldown_sec: u64, + pub log_file: Option, + pub plugin_ids: Vec, +} + +pub struct RunArgs { + pub app_data: PathBuf, + pub version: String, + pub plugins: Arc>, + pub interval_sec: u64, + pub threshold_percent: f64, + pub cooldown_sec: u64, + pub log_file: Option, + pub plugin_ids: Vec, + /// If true, stdout is the terminal (user ran `daemon` without `--detach`). + pub foreground: bool, +} + +pub fn spawn_detached(args: SpawnArgs) -> Result<()> { + let exe = std::env::current_exe().context("resolve current executable")?; + let mut cmd = Command::new(exe); + cmd.arg("daemon").arg("--child"); + cmd.arg("--interval-sec").arg(args.interval_sec.to_string()); + cmd.arg("--threshold-percent") + .arg(format!("{}", args.threshold_percent)); + cmd.arg("--cooldown-sec").arg(args.cooldown_sec.to_string()); + if let Some(ref p) = args.log_file { + cmd.arg("--log-file").arg(p); + } + for id in &args.plugin_ids { + cmd.arg(id); + } + + cmd.stdin(Stdio::null()); + #[cfg(unix)] + { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + + let child = cmd.spawn().context("spawn daemon child")?; + eprintln!( + "openusage-cli: background daemon started (pid {}). Log: {}", + child.id(), + args.log_file + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(none; use --log-file with --detach)".into()) + ); + Ok(()) +} + +pub fn run(args: RunArgs) -> Result<()> { + let selected: Vec<&LoadedPlugin> = if args.plugin_ids.is_empty() { + args.plugins.iter().collect() + } else { + let mut out = Vec::new(); + for id in &args.plugin_ids { + let p = args + .plugins + .iter() + .find(|x| x.manifest.id == *id) + .with_context(|| format!("Unknown plugin id: {id}"))?; + out.push(p); + } + out + }; + + if selected.is_empty() { + anyhow::bail!("No plugins to watch."); + } + + if args.foreground { + eprintln!( + "openusage-cli daemon: watching {} provider(s), every {}s (threshold {:.0}%, cooldown {}s). Ctrl+C to stop.", + selected.len(), + args.interval_sec, + args.threshold_percent, + args.cooldown_sec + ); + } + + let mut last_notify: HashMap = HashMap::new(); + let sleep = Duration::from_secs(args.interval_sec.max(5)); + + loop { + for plugin in &selected { + let out = runtime::run_probe(plugin, &args.app_data, &args.version); + let m = NormalizedMetricsMapper::from_output(&out); + + if m.primary_percent + f64::EPSILON < args.threshold_percent { + continue; + } + + let cooldown = Duration::from_secs(args.cooldown_sec.max(60)); + let now = Instant::now(); + let allow = last_notify + .get(&out.provider_id) + .map(|t| now.duration_since(*t) >= cooldown) + .unwrap_or(true); + + if !allow { + continue; + } + + let title = format!("{} — high usage", out.display_name); + let body = format!( + "Primary usage ~ {:.0}% (threshold {:.0}%).", + m.primary_percent, args.threshold_percent + ); + + match Notification::new() + .summary(&title) + .body(&body) + .appname("openusage-cli") + .show() + { + Ok(handle) => { + log::info!("notification id {:?}", handle); + last_notify.insert(out.provider_id.clone(), now); + } + Err(e) => { + log::warn!("desktop notification failed (headless session?): {e}"); + append_log( + args.log_file.as_deref(), + &format!("[WARN] notify failed for {}: {e}\n", out.provider_id), + )?; + } + } + + if args.foreground { + eprintln!( + "[daemon] alert: {} ~ {:.0}%", + out.display_name, m.primary_percent + ); + } + } + + append_log( + args.log_file.as_deref(), + &format!( + "[{}] polled {} plugins OK\n", + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), + selected.len() + ), + )?; + + std::thread::sleep(sleep); + } +} + +fn append_log(path: Option<&Path>, line: &str) -> Result<()> { + let Some(path) = path else { + return Ok(()); + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?; + } + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open log {:?}", path))?; + f.write_all(line.as_bytes()) + .with_context(|| format!("write log {:?}", path))?; + Ok(()) +} diff --git a/crates/openusage-cli/src/embedded_plugins.rs b/crates/openusage-cli/src/embedded_plugins.rs new file mode 100644 index 000000000..c90989d02 --- /dev/null +++ b/crates/openusage-cli/src/embedded_plugins.rs @@ -0,0 +1,43 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Same plugin set as `src-tauri/resources/bundled_plugins` (from `plugins/` at build time). +//! Used when no resource dir / empty install so `cargo install` and minimal builds still discover providers. + +use include_dir::{include_dir, Dir}; +use std::fs; +use std::io; +use std::path::Path; + +static PLUGINS_ROOT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../plugins"); + +fn materialize_dir(dir: &Dir<'_>, plugins_dest: &Path) -> io::Result<()> { + for entry in dir.entries() { + match entry { + include_dir::DirEntry::File(f) => { + let rel = f.path(); + let s = rel.to_string_lossy(); + if s.starts_with("mock/") || s.as_ref() == "mock" { + continue; + } + let out = plugins_dest.join(rel); + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + fs::write(out, f.contents())?; + } + include_dir::DirEntry::Dir(d) => { + if d.path() == Path::new("mock") { + continue; + } + materialize_dir(d, plugins_dest)?; + } + } + } + Ok(()) +} + +/// Writes embedded plugins into `app_data/plugins` (excluding `mock`). +pub fn materialize_into_app_data(app_data: &Path) -> io::Result<()> { + let dest = app_data.join("plugins"); + fs::create_dir_all(&dest)?; + materialize_dir(&PLUGINS_ROOT, &dest) +} diff --git a/crates/openusage-cli/src/history.rs b/crates/openusage-cli/src/history.rs new file mode 100644 index 000000000..1700df679 --- /dev/null +++ b/crates/openusage-cli/src/history.rs @@ -0,0 +1,200 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! CLI-owned snapshot history: ring buffers in the TUI + optional JSONL append. +//! One JSON object per line (easy to `export` / inspect with `jq`). + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use openusage_core::plugin_engine::runtime::PluginOutput; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; + +use crate::tui::view_model::{NormalizedMetrics, NormalizedMetricsMapper}; + +/// Single probe snapshot for export / persistence. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SnapshotRecord { + pub ts: String, + pub provider_id: String, + pub display_name: String, + pub plan: Option, + pub primary_percent: f64, + pub input_tokens: Option, + pub output_tokens: Option, + pub cost: Option, + pub reset_time: Option, +} + +pub fn history_jsonl_path() -> PathBuf { + dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("openusage") + .join("cli-history.jsonl") +} + +pub fn record_from_output(out: &PluginOutput, at: DateTime) -> SnapshotRecord { + let m = NormalizedMetricsMapper::from_output(out); + record_from_parts(out, &m, at) +} + +fn record_from_parts( + out: &PluginOutput, + m: &NormalizedMetrics, + at: DateTime, +) -> SnapshotRecord { + SnapshotRecord { + ts: at.to_rfc3339(), + provider_id: out.provider_id.clone(), + display_name: out.display_name.clone(), + plan: out.plan.clone(), + primary_percent: m.primary_percent, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + cost: m.cost, + reset_time: m.reset_time.clone(), + } +} + +/// Append one line to the default history file (used when `persist_history` is on in the TUI). +pub fn append_jsonl(rec: &SnapshotRecord) -> Result<()> { + let path = history_jsonl_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?; + } + let line = serde_json::to_string(rec).context("serialize history line")?; + let mut f = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open {:?}", path))?; + writeln!(f, "{line}").with_context(|| format!("write {:?}", path))?; + Ok(()) +} + +/// Read all records from a JSONL file (skips malformed lines). +pub fn read_jsonl(path: &std::path::Path) -> Result> { + let text = std::fs::read_to_string(path).with_context(|| format!("read {:?}", path))?; + let mut out = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(r) = serde_json::from_str::(line) { + out.push(r); + } + } + Ok(out) +} + +/// Fixed-capacity ring of primary usage % for sparklines. +#[derive(Debug, Clone)] +pub struct PercentRing { + cap: usize, + data: VecDeque, +} + +impl PercentRing { + pub fn new(cap: usize) -> Self { + Self { + cap: cap.max(4), + data: VecDeque::new(), + } + } + + pub fn push_percent(&mut self, primary: f64) { + let v = primary.clamp(0.0, 100.0) as u64; + if self.data.len() >= self.cap { + self.data.pop_front(); + } + self.data.push_back(v); + } + + /// Last up to 32 points, left-padded with zeros for short history. + pub fn sparkline_values(&self) -> Vec { + const W: usize = 32; + let v: Vec = self.data.iter().copied().collect(); + if v.is_empty() { + return vec![0; W]; + } + if v.len() >= W { + v[v.len() - W..].to_vec() + } else { + let mut out = vec![0u64; W - v.len()]; + out.extend(v); + out + } + } +} + +pub fn print_csv(records: &[SnapshotRecord]) -> Result<()> { + use std::io::{self, Write}; + let mut w = io::stdout().lock(); + writeln!( + w, + "ts,provider_id,display_name,plan,primary_percent,input_tokens,output_tokens,cost,reset_time" + )?; + for r in records { + writeln!( + w, + "{},{},{},{},{},{},{},{},{}", + csv_cell(&r.ts), + csv_cell(&r.provider_id), + csv_cell(&r.display_name), + csv_opt(&r.plan), + r.primary_percent, + csv_opt_u64(r.input_tokens), + csv_opt_u64(r.output_tokens), + csv_opt_f64(r.cost), + csv_opt(&r.reset_time), + )?; + } + Ok(()) +} + +fn csv_cell(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn csv_opt(s: &Option) -> String { + match s { + None => String::new(), + Some(v) => csv_cell(v), + } +} + +fn csv_opt_u64(o: Option) -> String { + match o { + None => String::new(), + Some(n) => n.to_string(), + } +} + +fn csv_opt_f64(o: Option) -> String { + match o { + None => String::new(), + Some(n) => format!("{n:.6}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_pads_sparkline() { + let mut r = PercentRing::new(8); + r.push_percent(10.0); + r.push_percent(20.0); + let v = r.sparkline_values(); + assert_eq!(v.len(), 32); + assert_eq!(v[v.len() - 2], 10); + assert_eq!(v[v.len() - 1], 20); + } +} diff --git a/crates/openusage-cli/src/main.rs b/crates/openusage-cli/src/main.rs new file mode 100644 index 000000000..3fe12264e --- /dev/null +++ b/crates/openusage-cli/src/main.rs @@ -0,0 +1,894 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! OpenUsage CLI — same plugin engine as the desktop app. +//! Fork: https://github.com/openusage-community/openusage · Upstream OpenUsage: https://github.com/robinebers/openusage + +mod batch_probe; +mod cli_width; +mod config; +mod cursor_token_usage; +mod daemon; +mod embedded_plugins; +mod history; +mod panic_hook; +mod reset_display; +mod tui; + +use anyhow::{bail, Context, Result}; +use chrono::Utc; +use clap::{Parser, Subcommand, ValueEnum}; +use openusage_core::paths::{self as cu_paths, ResourceDirResolution}; +use openusage_core::plugin_engine::runtime::{MetricLine, PluginOutput, ProgressFormat}; +use openusage_core::plugin_engine::{self, manifest::LoadedPlugin}; +use owo_colors::OwoColorize; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tabled::settings::object::Columns; +use tabled::settings::{Modify, Style, Width}; +use tabled::{Table, Tabled}; + +use crate::cli_width::CliTableLayout; +use crate::config::CliConfig; +use crate::reset_display::format_resets_at_for_display; +use crate::tui::view_model::NormalizedMetricsMapper; + +#[derive(Parser)] +#[command(name = "openusage-cli")] +#[command(version = env!("CARGO_PKG_VERSION"))] +#[command(about = "OpenUsage — AI subscription usage from the terminal")] +#[command( + after_long_help = "NOTE: --daemon runs background polling only and cannot be combined with a subcommand. For advanced daemon options use: openusage-cli daemon --help" +)] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Config file path (default: ~/.config/openusage/config.toml) + #[arg(long, global = true, value_name = "PATH")] + config: Option, + + /// Theme: dark | light | btop-rainbow | auto + #[arg(long, global = true)] + theme: Option, + + /// Override probe refresh interval (seconds) + #[arg(long, global = true)] + refresh_sec: Option, + + /// Disable mouse capture in the TUI + #[arg(long, global = true)] + no_mouse: bool, + + /// Background polling + desktop notifications only (no TUI). Cannot be used with a subcommand. + #[arg(long, global = true)] + daemon: bool, + + /// Output JSON instead of tables (for scripts) + #[arg(long, global = true)] + json: bool, + + /// No ANSI colors + #[arg(long, global = true)] + plain: bool, + + /// Show plugin host WARN/ERROR logs on stderr (default: hidden for dashboard) + #[arg(long, global = true)] + verbose: bool, + + /// Skip real plugin probes; use demo data (TUI input / layout test). Also: `dashboard --no-probe`. + #[arg(long, global = true)] + no_probe: bool, + + /// Skip the interactive provider checkbox screen; load all discovered providers (or those from CLI args) immediately. + #[arg(long, global = true)] + no_picker: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// List providers and probe current usage (table) + List { + /// Plugin ids (e.g. cursor). If empty, lists all. + plugin_ids: Vec, + }, + /// One-shot JSON probe (use --human for legacy table output) + Probe { + /// Plugin ids (e.g. cursor, claude). If empty, probes all. + plugin_ids: Vec, + /// Print human-readable tables instead of JSON + #[arg(long)] + human: bool, + }, + /// Full-screen btop-style dashboard (default if no subcommand) + #[command(visible_alias = "tui")] + Dashboard { + /// Plugin ids (e.g. cursor). If empty, probes all. + plugin_ids: Vec, + }, + /// Export usage snapshots as JSON or CSV (live probe, or read prior JSONL history) + Export { + #[arg(long, value_enum, default_value_t = ExportFormat::Json)] + format: ExportFormat, + #[arg(long)] + from_file: Option, + plugin_ids: Vec, + }, + /// Cursor per-model token usage (CSV export). Same data source as [cstats](https://github.com/robinebers/cstats); other providers are not supported. + #[command(name = "usage-stats", visible_alias = "cstats")] + UsageStats { + /// Only `cursor` is implemented (token CSV export). + #[arg(long, default_value = "cursor")] + provider: String, + #[arg(short = 's', long)] + since: Option, + #[arg(short = 'u', long)] + until: Option, + #[arg(short = 'g', long, default_value = "model")] + group: String, + #[arg(short = 'o', long, default_value = "summary")] + output: String, + }, + /// Poll in background and notify when usage is high (advanced; see also global --daemon) + Daemon { + #[arg(long)] + detach: bool, + #[arg(long, hide = true)] + child: bool, + #[arg(long, default_value_t = 30)] + interval_sec: u64, + #[arg(long, default_value_t = 85.0)] + threshold_percent: f64, + #[arg(long, default_value_t = 3600)] + cooldown_sec: u64, + #[arg(long)] + log_file: Option, + plugin_ids: Vec, + }, +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +enum ExportFormat { + #[default] + Json, + Csv, +} + +/// Match dashboard behavior: hide `log` / plugin-host WARN+ERROR on stderr unless `--verbose`. +fn apply_cli_log_policy(verbose: bool) { + if !verbose { + log::set_max_level(log::LevelFilter::Off); + } +} + +/// Alphabetical by provider `id` (stable, matches probe order when listing all). +fn sort_list_rows_by_id(rows: &mut Vec) { + rows.sort_by(|a, b| a.id.cmp(&b.id)); +} + +/// SIGINT/SIGTERM flag for batch commands (`list` / `probe` / live `export`) — checked between probes. +fn register_batch_interrupt_flag() -> Result> { + use signal_hook::consts::signal::SIGINT; + use signal_hook::flag as signal_flag; + + let flag = Arc::new(AtomicBool::new(false)); + signal_flag::register(SIGINT, Arc::clone(&flag)) + .context("register SIGINT for batch command")?; + #[cfg(unix)] + { + use signal_hook::consts::signal::SIGTERM; + signal_flag::register(SIGTERM, Arc::clone(&flag)) + .context("register SIGTERM for batch command")?; + } + Ok(flag) +} + +fn exit_if_batch_interrupted(flag: &Arc) { + if flag.load(Ordering::SeqCst) { + eprintln!("\nopenusage-cli: interrupted"); + std::process::exit(130); + } +} + +fn main() -> Result<()> { + panic_hook::install(); + let cli = Cli::parse(); + // Ignore SIGTSTP / SIGTTIN / SIGTTOU for every command (long probes, IDE terminals, bash + // `[1]+ Stopped`, etc.) — same idea as the interactive dashboard. + tui::ignore_sigtstp(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + let plain = cli.plain; + + if cli.daemon && cli.command.is_some() { + bail!( + "--daemon cannot be used with a subcommand. \ + Run `openusage-cli --daemon` alone, or use `openusage-cli daemon` for advanced options." + ); + } + + if let Some(Commands::UsageStats { + ref provider, + ref since, + ref until, + ref group, + ref output, + }) = &cli.command + { + apply_cli_log_policy(cli.verbose); + cursor_token_usage::run_usage_stats(cursor_token_usage::UsageStatsArgs { + provider: provider.clone(), + since: since.clone(), + until: until.clone(), + group: group.clone(), + output: output.clone(), + json: cli.json, + })?; + return Ok(()); + } + + let cu_paths::CliPaths { + app_data, + resource_dir, + resource_resolution, + } = resolve_install_paths()?; + + let version = env!("CARGO_PKG_VERSION").to_string(); + let (_plugin_dir, mut plugins) = + plugin_engine::initialize_plugins(&app_data, resource_dir.as_deref()); + if plugins.is_empty() { + if let Err(e) = embedded_plugins::materialize_into_app_data(&app_data) { + log::warn!( + "embedded plugins: could not unpack to {}: {e}", + app_data.display() + ); + } else { + let (_p2, p2) = plugin_engine::initialize_plugins(&app_data, resource_dir.as_deref()); + plugins = p2; + } + } + let plugins = Arc::new(plugins); + + let config_path = CliConfig::resolve_path(cli.config.clone()); + let mut cfg = CliConfig::load_from_path(&config_path); + cfg = cfg.merge_cli_overrides(cli.theme.as_deref(), cli.refresh_sec, cli.no_mouse); + + if cli.daemon { + if plugins.is_empty() { + bail!( + "No plugins discovered; nothing to watch.\n{}", + cu_paths::plugins_empty_diagnostic(&resource_resolution) + ); + } + return run_global_daemon(app_data, version, Arc::clone(&plugins), cli.verbose); + } + + match cli.command { + None => { + run_dashboard_cmd( + &cli, + &cfg, + &config_path, + &[], + &app_data, + &version, + &plugins, + &resource_resolution, + )?; + } + Some(Commands::UsageStats { .. }) => { + unreachable!("usage-stats is handled before plugin load") + } + Some(Commands::Dashboard { ref plugin_ids }) => { + run_dashboard_cmd( + &cli, + &cfg, + &config_path, + plugin_ids, + &app_data, + &version, + &plugins, + &resource_resolution, + )?; + } + Some(Commands::List { ref plugin_ids }) => { + run_list_cmd( + &cli, + &cfg, + plugin_ids, + &app_data, + &version, + &plugins, + plain, + &resource_resolution, + )?; + } + Some(Commands::Probe { + ref plugin_ids, + human, + }) => { + run_probe_cmd( + plugin_ids, + human, + &app_data, + &version, + &plugins, + plain, + cli.verbose, + &resource_resolution, + )?; + } + Some(Commands::Export { + format: export_fmt, + ref from_file, + ref plugin_ids, + }) => { + run_export_cmd( + export_fmt, + from_file.clone(), + plugin_ids.clone(), + &app_data, + &version, + &plugins, + cli.verbose, + &resource_resolution, + )?; + } + Some(Commands::Daemon { + detach, + child, + interval_sec, + threshold_percent, + cooldown_sec, + ref log_file, + ref plugin_ids, + }) => { + apply_cli_log_policy(cli.verbose); + if detach && !child { + if plugins.is_empty() { + bail!( + "No plugins discovered; nothing to watch.\n{}", + cu_paths::plugins_empty_diagnostic(&resource_resolution) + ); + } + daemon::spawn_detached(daemon::SpawnArgs { + interval_sec, + threshold_percent, + cooldown_sec, + log_file: log_file.clone(), + plugin_ids: plugin_ids.clone(), + })?; + return Ok(()); + } + daemon::run(daemon::RunArgs { + app_data, + version, + plugins: Arc::clone(&plugins), + interval_sec, + threshold_percent, + cooldown_sec, + log_file: log_file.clone(), + plugin_ids: plugin_ids.clone(), + foreground: !child, + })?; + } + } + + Ok(()) +} + +fn run_global_daemon( + app_data: PathBuf, + version: String, + plugins: Arc>, + verbose: bool, +) -> Result<()> { + apply_cli_log_policy(verbose); + daemon::run(daemon::RunArgs { + app_data, + version, + plugins, + interval_sec: 30, + threshold_percent: 85.0, + cooldown_sec: 3600, + log_file: None, + plugin_ids: vec![], + foreground: true, + }) +} + +fn run_dashboard_cmd( + cli: &Cli, + cfg: &CliConfig, + config_path: &PathBuf, + plugin_ids: &[String], + app_data: &PathBuf, + version: &str, + plugins: &Arc>, + resource_resolution: &ResourceDirResolution, +) -> Result<()> { + apply_cli_log_policy(cli.verbose); + + let selected_indices: Vec = if plugin_ids.is_empty() { + (0..plugins.len()).collect() + } else { + let mut out = Vec::new(); + for id in plugin_ids { + let idx = plugins + .iter() + .position(|x| x.manifest.id == *id) + .with_context(|| format!("Unknown plugin id: {id}"))?; + out.push(idx); + } + out + }; + + if selected_indices.is_empty() { + eprintln!( + "No plugins to show.\n{}", + cu_paths::plugins_empty_diagnostic(resource_resolution) + ); + return Ok(()); + } + + if cli.json { + let mut outputs: Vec = Vec::new(); + for &idx in &selected_indices { + let out = batch_probe::run_probe_with_timeout(&plugins[idx], app_data, version, None); + outputs.push(out); + } + println!("{}", serde_json::to_string_pretty(&outputs)?); + } else { + let tui_debug = cli.verbose || std::env::var_os("OPENUSAGE_TUI_DEBUG").is_some(); + let show_picker = !cli.no_picker && !cli.no_probe && plugin_ids.is_empty(); + tui::run( + cfg.clone(), + config_path.clone(), + app_data.clone(), + version.to_string(), + Arc::clone(plugins), + selected_indices, + show_picker, + cli.no_probe, + tui_debug, + )?; + } + Ok(()) +} + +fn run_list_cmd( + cli: &Cli, + _cfg: &CliConfig, + plugin_ids: &[String], + app_data: &PathBuf, + version: &str, + plugins: &Arc>, + plain: bool, + resource_resolution: &ResourceDirResolution, +) -> Result<()> { + apply_cli_log_policy(cli.verbose); + + let mut selected: Vec<&LoadedPlugin> = if plugin_ids.is_empty() { + plugins.iter().collect() + } else { + let mut out = Vec::new(); + for id in plugin_ids { + let p = plugins + .iter() + .find(|x| x.manifest.id == *id) + .with_context(|| format!("Unknown plugin id: {id}"))?; + out.push(p); + } + out + }; + + if plugin_ids.is_empty() { + selected.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id)); + } + + if selected.is_empty() { + eprintln!( + "No plugins to list.\n{}", + cu_paths::plugins_empty_diagnostic(resource_resolution) + ); + return Ok(()); + } + + if cli.json { + let names: Vec<_> = selected + .iter() + .map(|p| serde_json::json!({"id": p.manifest.id, "name": p.manifest.name})) + .collect(); + println!("{}", serde_json::to_string_pretty(&names)?); + return Ok(()); + } + + let interrupt = register_batch_interrupt_flag()?; + let n = selected.len(); + let tmax = batch_probe::probe_timeout_secs(); + eprintln!( + "openusage-cli: probing {n} provider(s)… (up to {tmax}s each — OPENUSAGE_PROBE_TIMEOUT_SEC; Ctrl+C between probes; not the TUI — `q` does nothing.)" + ); + + let mut rows: Vec = Vec::new(); + for (i, p) in selected.into_iter().enumerate() { + exit_if_batch_interrupted(&interrupt); + eprintln!("openusage-cli: [{}/{}] {}…", i + 1, n, p.manifest.id); + let out = batch_probe::run_probe_with_timeout(p, app_data, version, Some(&interrupt)); + let m = NormalizedMetricsMapper::from_output(&out); + + let mut input_s = m + .input_tokens + .map(|n| n.to_string()) + .unwrap_or_else(|| "—".into()); + let mut output_s = m + .output_tokens + .map(|n| n.to_string()) + .unwrap_or_else(|| "—".into()); + let mut cost_s = m + .cost + .map(|c| format!("{:.2}", c)) + .unwrap_or_else(|| "—".into()); + + if p.manifest.id == "cursor" || p.manifest.id == "cursor-nightly" { + if let Some(mtd) = + cursor_token_usage::fetch_cursor_month_to_date_totals_for_plugin(&p.manifest.id) + { + input_s = cursor_token_usage::format_token_count(mtd.input_tokens); + output_s = cursor_token_usage::format_token_count(mtd.output_tokens); + cost_s = format!("{:.2}", mtd.cost_usd); + } + } + + rows.push(ListUsageRow { + id: p.manifest.id.clone(), + name: p.manifest.name.clone(), + primary: format!("{:.0}%", m.primary_percent), + quota: m.list_quota_summary.clone().unwrap_or_else(|| "—".into()), + input: input_s, + output: output_s, + cost: cost_s, + }); + } + + sort_list_rows_by_id(&mut rows); + + if !plain { + print_banner(plain); + } + print_list_table_responsive(&rows); + Ok(()) +} + +fn print_list_table_responsive(rows: &[ListUsageRow]) { + let w = cli_width::terminal_width(); + match cli_width::list_layout_for_width(w) { + CliTableLayout::Stacked => print_list_stacked(rows, w as usize), + CliTableLayout::Full => { + let mut table = Table::new(rows); + table.with(Style::rounded()); + println!("{table}"); + } + // Kept for exhaustive match; list_layout_for_width only returns Stacked | Full. + CliTableLayout::Compact => print_list_stacked(rows, w as usize), + } +} + +fn print_list_stacked(rows: &[ListUsageRow], term_width: usize) { + let text_w = term_width.saturating_sub(4).max(20); + let rule_len = (term_width.saturating_sub(2)).clamp(12, 72); + let rule: String = std::iter::repeat('-').take(rule_len).collect(); + for (i, r) in rows.iter().enumerate() { + if i > 0 { + println!("{rule}"); + } + println!("{} · {} · primary {}", r.id, r.name, r.primary); + if r.quota != "—" { + let block = format!("Quota: {}", r.quota); + for line in cli_width::wrap_plain(&block, text_w).lines() { + println!(" {line}"); + } + } else { + println!(" Quota: —"); + } + println!( + " input: {} output: {} cost: {}", + r.input, r.output, r.cost + ); + println!(); + } +} + +fn run_probe_cmd( + plugin_ids: &[String], + human: bool, + app_data: &PathBuf, + version: &str, + plugins: &Arc>, + plain: bool, + verbose: bool, + resource_resolution: &ResourceDirResolution, +) -> Result<()> { + apply_cli_log_policy(verbose); + + let selected: Vec<&LoadedPlugin> = if plugin_ids.is_empty() { + plugins.iter().collect() + } else { + let mut out = Vec::new(); + for id in plugin_ids { + let p = plugins + .iter() + .find(|x| x.manifest.id == *id) + .with_context(|| format!("Unknown plugin id: {id}"))?; + out.push(p); + } + out + }; + + if selected.is_empty() { + eprintln!( + "No plugins to probe.\n{}", + cu_paths::plugins_empty_diagnostic(resource_resolution) + ); + return Ok(()); + } + + let interrupt = register_batch_interrupt_flag()?; + let n = selected.len(); + let tmax = batch_probe::probe_timeout_secs(); + eprintln!( + "openusage-cli: probing {n} provider(s)… (up to {tmax}s each — OPENUSAGE_PROBE_TIMEOUT_SEC; Ctrl+C between probes; not the TUI — `q` does nothing.)" + ); + + let mut outputs: Vec = Vec::new(); + for (i, plugin) in selected.into_iter().enumerate() { + exit_if_batch_interrupted(&interrupt); + eprintln!("openusage-cli: [{}/{}] {}…", i + 1, n, plugin.manifest.id); + log::info!("Probing {}", plugin.manifest.id); + let out = batch_probe::run_probe_with_timeout(plugin, app_data, version, Some(&interrupt)); + outputs.push(out); + } + + if human { + print_banner(plain); + for out in &outputs { + print_plugin_output(out, plain)?; + } + } else { + println!("{}", serde_json::to_string_pretty(&outputs)?); + } + Ok(()) +} + +fn run_export_cmd( + export_fmt: ExportFormat, + from_file: Option, + plugin_ids: Vec, + app_data: &PathBuf, + version: &str, + plugins: &Arc>, + verbose: bool, + resource_resolution: &ResourceDirResolution, +) -> Result<()> { + apply_cli_log_policy(verbose); + + let mut records = if let Some(ref path) = from_file { + history::read_jsonl(path)? + } else { + let selected: Vec<&LoadedPlugin> = if plugin_ids.is_empty() { + plugins.iter().collect() + } else { + let mut out = Vec::new(); + for id in &plugin_ids { + let p = plugins + .iter() + .find(|x| x.manifest.id == *id) + .with_context(|| format!("Unknown plugin id: {id}"))?; + out.push(p); + } + out + }; + if selected.is_empty() { + eprintln!( + "No plugins to export.\n{}", + cu_paths::plugins_empty_diagnostic(resource_resolution) + ); + return Ok(()); + } + let interrupt = register_batch_interrupt_flag()?; + let n = selected.len(); + let tmax = batch_probe::probe_timeout_secs(); + eprintln!( + "openusage-cli: exporting live probe for {n} provider(s)… (up to {tmax}s each — OPENUSAGE_PROBE_TIMEOUT_SEC; Ctrl+C between probes.)" + ); + let mut recs = Vec::new(); + for (i, plugin) in selected.into_iter().enumerate() { + exit_if_batch_interrupted(&interrupt); + eprintln!("openusage-cli: [{}/{}] {}…", i + 1, n, plugin.manifest.id); + let out = + batch_probe::run_probe_with_timeout(plugin, app_data, version, Some(&interrupt)); + recs.push(history::record_from_output(&out, Utc::now())); + } + recs + }; + + if !plugin_ids.is_empty() && from_file.is_some() { + let ids: std::collections::HashSet<_> = plugin_ids.iter().cloned().collect(); + records.retain(|r| ids.contains(&r.provider_id)); + } + + match export_fmt { + ExportFormat::Json => println!("{}", serde_json::to_string_pretty(&records)?), + ExportFormat::Csv => history::print_csv(&records)?, + } + Ok(()) +} + +#[derive(Tabled)] +struct ListUsageRow { + id: String, + name: String, + primary: String, + #[tabled(rename = "Quota (per model)")] + quota: String, + input: String, + output: String, + cost: String, +} + +#[derive(Tabled, Clone)] +struct LineRowProbe { + label: String, + value: String, +} + +fn print_banner(plain: bool) { + if plain { + println!("OpenUsage CLI {}", env!("CARGO_PKG_VERSION")); + println!("Fork: https://github.com/openusage-community/openusage"); + println!("Upstream OpenUsage (Robin Ebers): https://github.com/robinebers/openusage"); + } else { + println!( + "{} {}", + "OpenUsage CLI".bold().cyan(), + env!("CARGO_PKG_VERSION").dimmed() + ); + println!( + "{}", + "Fork: github.com/openusage-community/openusage".dimmed() + ); + println!( + "{}", + "Upstream: OpenUsage by Robin Ebers — github.com/robinebers/openusage".dimmed() + ); + println!(); + } +} + +fn print_plugin_output(out: &PluginOutput, plain: bool) -> Result<()> { + let title = format!("{} ({})", out.display_name, out.provider_id); + if plain { + println!("=== {title} ==="); + if let Some(ref plan) = out.plan { + println!("Plan: {plan}"); + } + } else { + println!("{}", title.bold().green()); + if let Some(ref plan) = out.plan { + println!("{} {}", "Plan:".dimmed(), plan); + } + } + + let mut rows: Vec = Vec::new(); + for line in &out.lines { + match line { + MetricLine::Text { + label, + value, + subtitle, + .. + } => { + let mut v = value.clone(); + if let Some(s) = subtitle { + v.push_str(&format!(" ({s})")); + } + rows.push(LineRowProbe { + label: label.clone(), + value: v, + }); + } + MetricLine::Progress { + label, + used, + limit, + format, + resets_at, + .. + } => { + let pct = if *limit > 0.0 { + (used / limit) * 100.0 + } else { + 0.0 + }; + let mut v = match format { + ProgressFormat::Percent => format!("{:.1}% ({:.0} / {:.0})", pct, used, limit), + ProgressFormat::Dollars => format!("${:.2} / ${:.2}", used, limit), + ProgressFormat::Count { suffix } => { + format!("{:.0} / {:.0} {}", used, limit, suffix) + } + }; + if let Some(r) = resets_at { + let rel = format_resets_at_for_display(r); + if !rel.is_empty() { + v.push_str(&format!(" · {rel}")); + } + } + rows.push(LineRowProbe { + label: label.clone(), + value: v, + }); + } + MetricLine::Badge { + label, + text, + subtitle, + .. + } => { + let mut v = text.clone(); + if let Some(s) = subtitle { + v.push_str(&format!(" ({s})")); + } + rows.push(LineRowProbe { + label: label.clone(), + value: v, + }); + } + MetricLine::BarChart { .. } => {} + } + } + + if !rows.is_empty() { + print_probe_human_table(&rows); + } + println!(); + Ok(()) +} + +fn print_probe_human_table(rows: &[LineRowProbe]) { + let w = cli_width::terminal_width(); + let layout = cli_width::layout_for_width(w); + let wrap = cli_width::probe_value_column_wrap_chars(w); + match layout { + CliTableLayout::Stacked => { + let tw = (w as usize).saturating_sub(2).max(12); + for row in rows { + println!("{}:", row.label); + for line in cli_width::wrap_plain(&row.value, tw).lines() { + println!(" {line}"); + } + } + } + CliTableLayout::Compact => { + let mut table = Table::new(rows); + table + .with(Style::modern()) + .with(Modify::new(Columns::single(1)).with(Width::truncate(wrap).suffix("…"))); + println!("{table}"); + } + CliTableLayout::Full => { + let mut table = Table::new(rows); + table.with(Style::rounded()).with( + Modify::new(Columns::single(1)) + .with(Width::truncate(wrap.max(40).min(120)).suffix("…")), + ); + println!("{table}"); + } + } +} + +fn resolve_install_paths() -> Result { + cu_paths::resolve_cli_paths().map_err(|e| match e { + cu_paths::PathsError::NoAppDataDir => anyhow::anyhow!( + "Could not resolve application data directory (e.g. HOME / XDG_DATA_HOME, or Windows AppData). \ + Setting OPENUSAGE_RESOURCES does not fix this." + ), + }) +} diff --git a/crates/openusage-cli/src/panic_hook.rs b/crates/openusage-cli/src/panic_hook.rs new file mode 100644 index 000000000..fd7610413 --- /dev/null +++ b/crates/openusage-cli/src/panic_hook.rs @@ -0,0 +1,51 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Panic hook: restore terminal (raw mode / alternate screen / cursor) before printing panic; +//! then wait for a key so the user can read the message (best-effort). + +use crossterm::cursor::Show; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; +use std::io::{self, Read, Write}; + +/// Best-effort restore so panics don't leave the terminal unusable. +pub fn restore_terminal() { + let _ = disable_raw_mode(); + let mut out = io::stderr(); + let _ = execute!(out, LeaveAlternateScreen, Show); + let _ = out.flush(); +} + +fn press_any_key() { + let _ = io::stderr().write_all(b"\nPress any key to exit...\n"); + let _ = io::stderr().flush(); + + #[cfg(unix)] + { + if let Ok(mut f) = std::fs::File::open("/dev/tty") { + let mut buf = [0u8; 1]; + let _ = f.read_exact(&mut buf); + } else { + let mut buf = [0u8; 1]; + let _ = io::stdin().read_exact(&mut buf); + } + } + #[cfg(windows)] + { + let mut buf = [0u8; 1]; + let _ = io::stdin().read_exact(&mut buf); + } +} + +pub fn install() { + let _ = color_eyre::config::HookBuilder::default() + .display_location_section(true) + .display_env_section(false) + .install(); + + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + original(info); + press_any_key(); + })); +} diff --git a/crates/openusage-cli/src/reset_display.rs b/crates/openusage-cli/src/reset_display.rs new file mode 100644 index 000000000..03c314942 --- /dev/null +++ b/crates/openusage-cli/src/reset_display.rs @@ -0,0 +1,95 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Human-friendly "resets in …" strings instead of raw ISO timestamps where possible. + +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, TimeZone, Utc}; + +fn parse_reset_to_local(s: &str) -> Option> { + let s = s.trim(); + if s.is_empty() { + return None; + } + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Local)); + } + if let Ok(dt) = s.parse::>() { + return Some(dt.with_timezone(&Local)); + } + for fmt in [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ] { + if let Ok(ndt) = NaiveDateTime::parse_from_str(s, fmt) { + if let Some(dt) = Local.from_local_datetime(&ndt).single() { + return Some(dt); + } + } + } + if let Ok(nd) = NaiveDate::parse_from_str(s.split('T').next().unwrap_or(s), "%Y-%m-%d") { + if let Some(dt) = nd + .and_hms_opt(0, 0, 0) + .and_then(|t| Local.from_local_datetime(&t).single()) + { + return Some(dt); + } + } + None +} + +fn format_duration_until(target: DateTime) -> Option { + let now = Local::now(); + if target <= now { + return None; + } + let d = target.signed_duration_since(now); + let secs = d.num_seconds().max(0); + if secs < 60 { + return Some("resets in <1 min".into()); + } + let mins = secs / 60; + if mins < 60 { + return Some(format!("resets in {mins} min")); + } + let hours = mins / 60; + if hours < 48 { + let hm = mins % 60; + if hm == 0 { + return Some(format!("resets in {hours}h")); + } + return Some(format!("resets in {hours}h {hm}m")); + } + let days = hours / 24; + if days < 60 { + return Some(format!("resets in {days}d")); + } + let weeks = days / 7; + Some(format!("resets in {weeks}w")) +} + +/// Replace raw timestamps in progress lines with relative text when we can parse them. +pub fn format_resets_at_for_display(raw: &str) -> String { + let t = raw.trim(); + if t.is_empty() { + return String::new(); + } + if let Some(dt) = parse_reset_to_local(t) { + if let Some(rel) = format_duration_until(dt) { + return rel; + } + return format!("resets {}", dt.format("%Y-%m-%d %H:%M")); + } + format!("resets {t}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_future_rfc3339() { + let future = (Local::now() + chrono::Duration::hours(5)).to_rfc3339(); + let s = format_resets_at_for_display(&future); + assert!(s.contains("resets in"), "{s}"); + assert!(s.contains('h') || s.contains("min"), "{s}"); + } +} diff --git a/crates/openusage-cli/src/tui/app.rs b/crates/openusage-cli/src/tui/app.rs new file mode 100644 index 000000000..63a619426 --- /dev/null +++ b/crates/openusage-cli/src/tui/app.rs @@ -0,0 +1,1103 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Ratatui main loop: non-blocking input, tokio background probes, SIGINT handling. + +use std::io::{stdin, stdout, IsTerminal, Stdout}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + MouseEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use openusage_core::plugin_engine::manifest::LoadedPlugin; +use openusage_core::plugin_engine::runtime::{self, MetricLine, PluginOutput, ProgressFormat}; +use ratatui::layout::Alignment; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::symbols; +use ratatui::widgets::{ + Axis, Block, Borders, Chart, Clear, Dataset, Gauge, GraphType, List, ListItem, Paragraph, Wrap, +}; + +use super::platform; +use super::state::AppState; +use super::theme::{Theme, ThemePreset}; +use super::view_model::NormalizedMetricsMapper; +use crate::config::CliConfig; +use crate::reset_display::format_resets_at_for_display; + +/// Poll interval so the main loop never blocks long enough to miss input (ms). +const EVENT_POLL_MS: u64 = 75; + +fn probe_timeout_secs() -> u64 { + std::env::var("OPENUSAGE_PROBE_TIMEOUT_SEC") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&s| s > 0) + .unwrap_or(120) +} + +/// Runs `run_probe` in `spawn_blocking` with a wall-clock timeout so one hung provider cannot block the dashboard. +async fn run_probe_with_timeout( + plugins: Arc>, + idx: usize, + app_data: PathBuf, + version: String, + plugin_for_err: LoadedPlugin, +) -> PluginOutput { + let plugin_id = plugin_for_err.manifest.id.clone(); + let timeout_sec = probe_timeout_secs(); + let fut = async move { + tokio::task::spawn_blocking(move || runtime::run_probe(&plugins[idx], &app_data, &version)) + .await + }; + match tokio::time::timeout(Duration::from_secs(timeout_sec), fut).await { + Ok(Ok(o)) => o, + Ok(Err(e)) => probe_error_output( + &plugin_for_err, + format!("probe task panicked or cancelled: {e}"), + ), + Err(_) => { + eprintln!( + "openusage-cli: probe timed out after {timeout_sec}s for provider `{plugin_id}` \ + (increase OPENUSAGE_PROBE_TIMEOUT_SEC or disable that plugin)." + ); + probe_error_output( + &plugin_for_err, + format!( + "Probe timed out after {timeout_sec}s. The provider may be waiting on the network — \ + set OPENUSAGE_PROBE_TIMEOUT_SEC or run `openusage-cli list` to see which plugin hangs." + ), + ) + } + } +} + +enum ProbeMsg { + Progress { current: usize, total: usize }, + Done(usize, PluginOutput, bool), +} + +fn tui_trace(debug: bool, msg: &str) { + if debug { + eprintln!("[openusage-cli tui] {msg}"); + } +} + +fn register_shutdown_flag() -> Result> { + use signal_hook::consts::signal::SIGINT; + use signal_hook::flag as signal_flag; + + let shutdown = Arc::new(AtomicBool::new(false)); + signal_flag::register(SIGINT, Arc::clone(&shutdown)).context("register SIGINT")?; + #[cfg(unix)] + { + use signal_hook::consts::signal::SIGTERM; + signal_flag::register(SIGTERM, Arc::clone(&shutdown)).context("register SIGTERM")?; + } + Ok(shutdown) +} + +fn loading_placeholder(plugin: &LoadedPlugin) -> PluginOutput { + PluginOutput { + provider_id: plugin.manifest.id.clone(), + display_name: plugin.manifest.name.clone(), + plan: None, + warning: None, + lines: vec![MetricLine::Text { + label: "Status".into(), + value: "Waiting for probe…".into(), + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }], + icon_url: plugin.icon_data_url.clone(), + } +} + +fn fake_plugin_output(plugin: &LoadedPlugin, i: usize) -> PluginOutput { + let used = 35.0 + ((i * 7) % 45) as f64; + PluginOutput { + provider_id: plugin.manifest.id.clone(), + display_name: format!("{} (demo)", plugin.manifest.name), + plan: Some("demo".into()), + warning: None, + lines: vec![MetricLine::Progress { + label: "primary".into(), + used, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: None, + period_duration_ms: None, + color: None, + }], + icon_url: plugin.icon_data_url.clone(), + } +} + +fn probe_error_output(plugin: &LoadedPlugin, message: String) -> PluginOutput { + PluginOutput { + provider_id: plugin.manifest.id.clone(), + display_name: plugin.manifest.name.clone(), + plan: None, + warning: None, + lines: vec![MetricLine::Text { + label: "Error".into(), + value: message, + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }], + icon_url: plugin.icon_data_url.clone(), + } +} + +fn spawn_initial_probe_batch( + handle: &tokio::runtime::Handle, + tx: Sender, + plugins: Arc>, + selected_indices: Vec, + app_data: PathBuf, + version: String, +) { + let n = selected_indices.len(); + handle.spawn(async move { + for (pos, &idx) in selected_indices.iter().enumerate() { + let _ = tx.send(ProbeMsg::Progress { + current: pos + 1, + total: n, + }); + let plugin_for_err = plugins[idx].clone(); + let plugins_c = Arc::clone(&plugins); + let app_data = app_data.clone(); + let version = version.clone(); + let out = + run_probe_with_timeout(plugins_c, idx, app_data, version, plugin_for_err).await; + let is_last = pos + 1 == n; + let _ = tx.send(ProbeMsg::Done(pos, out, is_last)); + } + }); +} + +pub fn run( + config: CliConfig, + config_path: PathBuf, + app_data: PathBuf, + version: String, + plugins: Arc>, + candidate_indices: Vec, + show_picker: bool, + no_probe: bool, + tui_debug: bool, +) -> Result<()> { + tui_trace(tui_debug, "run(): enter"); + if candidate_indices.is_empty() { + return Ok(()); + } + + let shutdown = register_shutdown_flag().context("signal-hook register")?; + + if !stdin().is_terminal() || !stdout().is_terminal() { + anyhow::bail!( + "The dashboard needs an interactive terminal (stdin and stdout must be TTYs). \ + Open a real terminal window, or use `openusage-cli list` / `openusage-cli probe` for non-interactive output." + ); + } + + platform::ignore_sigtstp(); + + let selected_indices = if show_picker { + tui_trace(tui_debug, "run(): provider picker"); + super::picker::run_provider_picker(&plugins, &candidate_indices, &shutdown, &config)? + } else { + candidate_indices + }; + + if selected_indices.is_empty() { + eprintln!("No providers selected."); + return Ok(()); + } + + eprintln!( + "openusage-cli: starting dashboard — first probe runs in the background; use q to quit, Ctrl+C to exit. \ + Tip: `openusage-cli --no-probe` skips probes; `--no-picker` skips the checkbox screen." + ); + if platform::parent_process_is_cargo() { + eprintln!( + "openusage-cli: you are running under `cargo run` (parent: cargo). \ + Prefer `./target/debug/openusage-cli` for a cleaner TTY." + ); + } + + let placeholder: Vec = selected_indices + .iter() + .map(|&idx| loading_placeholder(&plugins[idx])) + .collect(); + + let mut state = AppState::new(config_path, config.clone(), placeholder); + let n_sel = selected_indices.len(); + state.probe_progress = Some((0, n_sel)); + state.probe_busy = true; + state.refreshing = true; + + let (tx, rx) = mpsc::channel::(); + + let rt = tokio::runtime::Runtime::new().context("tokio::Runtime::new")?; + let handle = rt.handle().clone(); + + if no_probe { + tui_trace(tui_debug, "run(): --no-probe, filling fake data"); + for (i, &idx) in selected_indices.iter().enumerate() { + state.outputs[i] = fake_plugin_output(&plugins[idx], i); + } + state.initial_load_complete = true; + state.probe_busy = false; + state.refreshing = false; + state.probe_progress = None; + } else { + spawn_initial_probe_batch( + &handle, + tx.clone(), + Arc::clone(&plugins), + selected_indices.clone(), + app_data.clone(), + version.clone(), + ); + } + + enable_raw_mode().context("enable_raw_mode")?; + let mut out = stdout(); + execute!(out, EnterAlternateScreen, Hide).context("enter alternate screen")?; + if state.config.mouse { + execute!(out, EnableMouseCapture)?; + } + let backend = CrosstermBackend::new(out); + let mut terminal = Terminal::new(backend)?; + let mut tick = Instant::now(); + let mut layout_ratios = normalize_ratios(config.pane_ratios); + + let result = run_inner( + &mut terminal, + &mut state, + &plugins, + &selected_indices, + &app_data, + &version, + &rx, + &tx, + &mut tick, + &mut layout_ratios, + &handle, + &shutdown, + tui_debug, + ); + + let term_out = terminal.backend_mut(); + if state.config.mouse { + let _ = execute!(term_out, DisableMouseCapture); + } + let _ = execute!(term_out, LeaveAlternateScreen, Show); + disable_raw_mode()?; + result +} + +/// Each pane keeps at least this % width so columns never collapse to 0 (e.g. only Metrics visible) +/// after bad config, rounding, or mouse drag. +const MIN_PANE_PCT: u16 = 12; + +fn enforce_min_pane_ratios(mut out: [u16; 3]) -> [u16; 3] { + const MIN: u16 = MIN_PANE_PCT; + let mut sum: u32 = out.iter().map(|&x| x as u32).sum(); + if sum == 0 { + return [22, 48, 30]; + } + for i in 0..3 { + if out[i] < MIN { + sum += (MIN - out[i]) as u32; + out[i] = MIN; + } + } + if sum > 100 { + let mut excess = (sum - 100) as u16; + while excess > 0 { + let mut mi = 0usize; + let mut mv = 0u16; + for i in 0..3 { + if out[i] > mv { + mv = out[i]; + mi = i; + } + } + let take = excess.min(out[mi].saturating_sub(MIN)); + if take == 0 { + break; + } + out[mi] -= take; + excess -= take; + } + } else if sum < 100 { + out[1] = out[1].saturating_add((100 - sum) as u16); + } + out +} + +fn normalize_ratios(r: [u16; 3]) -> [u16; 3] { + let sum: u32 = r.iter().map(|&x| x as u32).sum(); + if sum == 0 { + return [22, 48, 30]; + } + let scale = 100u32; + let mut out = [0u16; 3]; + let mut acc = 0u32; + for i in 0..3 { + let v = (r[i] as u32 * scale + sum / 2) / sum; + out[i] = v.min(100) as u16; + acc += out[i] as u32; + } + // Rounding can make acc slightly below or above 100; never subtract unsigned (acc - scale) + // when acc > scale — that panics in debug (`attempt to subtract with overflow`). + if acc != scale && acc > 0 { + if acc < scale { + out[1] = out[1].saturating_add((scale - acc) as u16); + } else { + out[1] = out[1].saturating_sub((acc - scale) as u16); + } + } + enforce_min_pane_ratios(out) +} + +#[allow(clippy::too_many_arguments)] +fn run_inner( + terminal: &mut Terminal>, + state: &mut AppState, + plugins: &Arc>, + selected_indices: &[usize], + app_data: &PathBuf, + version: &str, + rx: &Receiver, + tx: &Sender, + tick: &mut Instant, + layout_ratios: &mut [u16; 3], + handle: &tokio::runtime::Handle, + shutdown: &Arc, + tui_debug: bool, +) -> Result<()> { + let mut scroll_detail: u16 = 0; + let mut sys = sysinfo::System::new_all(); + + loop { + if shutdown.load(Ordering::SeqCst) { + tui_trace( + tui_debug, + "run_inner: shutdown flag (SIGINT/SIGTERM), exiting", + ); + return Ok(()); + } + + while let Ok(msg) = rx.try_recv() { + tui_trace(tui_debug, "run_inner: recv probe msg"); + match msg { + ProbeMsg::Progress { current, total } => { + state.probe_progress = Some((current, total)); + } + ProbeMsg::Done(i, out, is_last) => { + if i < state.outputs.len() { + let m = NormalizedMetricsMapper::from_output(&out); + if i < state.rings.len() { + state.rings[i].push_percent(m.primary_percent); + } + if state.config.persist_history { + let rec = crate::history::record_from_output(&out, chrono::Utc::now()); + let _ = crate::history::append_jsonl(&rec); + } + state.outputs[i] = out; + state.last_probe[i] = Some(Instant::now()); + } + if is_last { + state.probe_busy = false; + state.refreshing = false; + state.probe_progress = None; + state.initial_load_complete = true; + } + } + } + } + + if state.last_sysinfo.elapsed() >= Duration::from_secs(1) { + state.last_sysinfo = Instant::now(); + sys.refresh_cpu_all(); + sys.refresh_memory(); + state.host_cpu_pct = sys.global_cpu_usage(); + state.host_mem_used_mb = sys.used_memory() / 1024 / 1024; + state.host_mem_total_mb = sys.total_memory().max(1) / 1024 / 1024; + } + + if state.probe_busy || state.refreshing { + state.spinner_frame = state.spinner_frame.wrapping_add(1); + } + + tui_trace(tui_debug, "draw()"); + terminal.draw(|f| { + let size = f.area(); + let theme = Theme::from_preset(ThemePreset::parse(&state.config.theme)); + if !state.initial_load_complete { + draw_loading_screen(f, size, state, &theme); + } else { + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(size); + + draw_header(f, root[0], state, &theme); + let main_area = root[1]; + let footer_area = root[2]; + + let h = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(layout_ratios[0] as u16), + Constraint::Percentage(layout_ratios[1] as u16), + Constraint::Percentage(layout_ratios[2] as u16), + ]) + .split(main_area); + + draw_provider_list(f, h[0], state, &theme); + draw_charts(f, h[1], state, &theme); + draw_detail_table(f, h[2], state, &theme, scroll_detail); + + draw_status_bar(f, footer_area, state, &theme); + + if state.help_open { + draw_help_modal(f, size, &theme); + } + } + })?; + + tui_trace(tui_debug, &format!("event::poll({EVENT_POLL_MS}ms)")); + if event::poll(Duration::from_millis(EVENT_POLL_MS))? { + loop { + let ev = event::read()?; + tui_trace(tui_debug, &format!("event read: {ev:?}")); + if handle_terminal_event( + ev, + state, + plugins, + selected_indices, + app_data, + version, + tx, + terminal, + layout_ratios, + &mut scroll_detail, + handle, + tui_debug, + )? { + return Ok(()); + } + if !event::poll(Duration::ZERO)? { + break; + } + } + } + + if tick.elapsed() >= state.ui_tick() { + *tick = Instant::now(); + poll_config_reload(state); + maybe_schedule_probe( + state, + plugins, + selected_indices, + app_data, + version, + tx, + handle, + ); + } + } +} + +/// Returns `true` if the app should exit (user quit). +#[allow(clippy::too_many_arguments)] +fn handle_terminal_event( + ev: Event, + state: &mut AppState, + plugins: &Arc>, + selected_indices: &[usize], + app_data: &PathBuf, + version: &str, + tx: &Sender, + terminal: &mut Terminal>, + layout_ratios: &mut [u16; 3], + scroll_detail: &mut u16, + handle: &tokio::runtime::Handle, + tui_debug: bool, +) -> Result { + let initial = !state.initial_load_complete; + + match ev { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if state.help_open { + match key.code { + KeyCode::Esc | KeyCode::Char('?') => state.help_open = false, + KeyCode::Char('q') | KeyCode::Char('Q') => { + tui_trace(tui_debug, "key q in help: exit"); + return Ok(true); + } + _ => {} + } + return Ok(false); + } + + // During initial probe: any quit key exits immediately (no confirm). + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) + { + tui_trace(tui_debug, "Ctrl+C key: exit"); + return Ok(true); + } + if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q')) { + tui_trace(tui_debug, "key q: exit"); + return Ok(true); + } + + if initial { + tui_trace(tui_debug, "initial load: ignoring non-quit keys"); + return Ok(false); + } + + match key.code { + KeyCode::Char('?') => state.help_open = true, + KeyCode::Tab => state.cycle_pane(), + KeyCode::Char('p') | KeyCode::Char('P') => { + state.config.low_power_mode = !state.config.low_power_mode; + let _ = state.config.save_to(&state.config_path); + state.status_msg = Some(format!("Low-power: {}", state.config.low_power_mode)); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + spawn_full_probe( + state, + plugins, + selected_indices, + app_data, + version, + tx, + handle, + ); + } + KeyCode::Left => { + state.chart_scroll = state.chart_scroll.saturating_add(1); + } + KeyCode::Right => { + state.chart_scroll = state.chart_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if state.selected + 1 < state.outputs.len() { + state.selected += 1; + } + } + KeyCode::Up | KeyCode::Char('k') => { + if state.selected > 0 { + state.selected -= 1; + } + } + KeyCode::PageDown => *scroll_detail = scroll_detail.saturating_add(5), + KeyCode::PageUp => *scroll_detail = scroll_detail.saturating_sub(5), + _ => {} + } + } + Event::Mouse(m) => { + if !state.initial_load_complete { + return Ok(false); + } + if state.config.mouse { + if let MouseEventKind::Drag(_) = m.kind { + let w = terminal.size()?.width; + if w > 0 { + let px = m.column as f64 / w as f64; + let mut r = *layout_ratios; + let left = (px * 100.0).clamp(10.0, 80.0) as u16; + r[0] = left; + r[1] = (100u16).saturating_sub(left).saturating_sub(r[2]).max(15); + *layout_ratios = normalize_ratios(r); + } + } + } + } + _ => {} + } + Ok(false) +} + +fn draw_loading_screen(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let (cur, tot) = state + .probe_progress + .unwrap_or((0, state.outputs.len().max(1))); + let ratio = if tot > 0 { + (cur as f64 / tot as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" OpenUsage ", theme.title)); + let inner = block.inner(area); + f.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Min(2), + Constraint::Length(2), + ]) + .split(inner); + + let title = Paragraph::new("OpenUsage — Loading…") + .style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)) + .alignment(Alignment::Center); + f.render_widget(title, chunks[0]); + + let gauge = Gauge::default() + .block(Block::default().borders(Borders::NONE)) + .gauge_style(Style::default().fg(theme.accent).bg(theme.border)) + .ratio(ratio); + f.render_widget(gauge, chunks[1]); + + let line = format!("Probing providers… {cur}/{tot} (q or Ctrl+C to quit)"); + let p = Paragraph::new(line) + .style(Style::default().fg(theme.muted)) + .alignment(Alignment::Center); + f.render_widget(p, chunks[2]); +} + +fn poll_config_reload(state: &mut AppState) { + if state.last_config_poll.elapsed() < Duration::from_secs(2) { + return; + } + state.last_config_poll = Instant::now(); + let path = &state.config_path; + let Ok(meta) = path.metadata() else { + return; + }; + let Ok(mtime) = meta.modified() else { + return; + }; + if state.config_path_mtime != Some(mtime) { + state.config = CliConfig::load_from_path(path); + state.config_path_mtime = Some(mtime); + } +} + +fn maybe_schedule_probe( + state: &mut AppState, + plugins: &Arc>, + selected_indices: &[usize], + app_data: &PathBuf, + version: &str, + tx: &Sender, + handle: &tokio::runtime::Handle, +) { + if state.probe_busy || selected_indices.is_empty() { + return; + } + let interval = state.effective_probe_interval(); + let now = Instant::now(); + let full_every = Duration::from_secs(30); + if now.duration_since(state.last_full_probe) >= full_every { + spawn_full_probe( + state, + plugins, + selected_indices, + app_data, + version, + tx, + handle, + ); + state.last_full_probe = now; + return; + } + + let i = state.rr_index % selected_indices.len(); + let last = state.last_probe.get(i).copied().flatten(); + let need = last.map(|t| t.elapsed() >= interval).unwrap_or(true); + if need { + state.rr_index = (state.rr_index + 1) % selected_indices.len(); + state.probe_busy = true; + state.refreshing = true; + let idx = selected_indices[i]; + let plugin_for_err = plugins[idx].clone(); + let plugins_block = Arc::clone(plugins); + let app_data = app_data.clone(); + let version = version.to_string(); + let tx = tx.clone(); + handle.spawn(async move { + let _ = tx.send(ProbeMsg::Progress { + current: 1, + total: 1, + }); + let out = + run_probe_with_timeout(plugins_block, idx, app_data, version, plugin_for_err).await; + let _ = tx.send(ProbeMsg::Done(i, out, true)); + }); + } +} + +fn spawn_full_probe( + state: &mut AppState, + plugins: &Arc>, + selected_indices: &[usize], + app_data: &PathBuf, + version: &str, + tx: &Sender, + handle: &tokio::runtime::Handle, +) { + if state.probe_busy { + return; + } + state.probe_busy = true; + state.refreshing = true; + let plugins = Arc::clone(plugins); + let app_data = app_data.clone(); + let version = version.to_string(); + let indices = selected_indices.to_vec(); + let tx = tx.clone(); + let n = indices.len(); + handle.spawn(async move { + for (pos, &idx) in indices.iter().enumerate() { + let _ = tx.send(ProbeMsg::Progress { + current: pos + 1, + total: n, + }); + let plugin_for_err = plugins[idx].clone(); + let plugins_block = Arc::clone(&plugins); + let app_data = app_data.clone(); + let version = version.clone(); + let out = + run_probe_with_timeout(plugins_block, idx, app_data, version, plugin_for_err).await; + let is_last = pos + 1 == n; + let _ = tx.send(ProbeMsg::Done(pos, out, is_last)); + } + }); +} + +fn draw_header(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + let spend = best_effort_spend_today(&state.outputs); + // host_* = this machine (sysinfo), not Cursor/API usage. + let line = format!( + " OpenUsage | {} | host CPU {:4.1}% MEM {}/{} MiB | est. spend: {} ", + now, state.host_cpu_pct, state.host_mem_used_mb, state.host_mem_total_mb, spend + ); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.bg)); + let inner = block.inner(area); + let p = Paragraph::new(line).style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)); + f.render_widget(block, area); + f.render_widget(p, inner); +} + +fn best_effort_spend_today(outputs: &[PluginOutput]) -> String { + let mut sum = 0.0; + let mut any = false; + for o in outputs { + let m = NormalizedMetricsMapper::from_output(o); + if let Some(c) = m.cost { + sum += c; + any = true; + } + } + if any { + format!("${sum:.2}") + } else { + "—".into() + } +} + +fn draw_provider_list(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" Providers ", theme.title)); + let items: Vec = state + .outputs + .iter() + .enumerate() + .map(|(i, o)| { + let m = NormalizedMetricsMapper::from_output(o); + let stale = state + .stale_secs(i) + .map(|s| format!(" {s}s")) + .unwrap_or_default(); + let line = format!("{} {:>4.0}%{}", o.display_name, m.primary_percent, stale); + let style = if i == state.selected { + Style::default() + .bg(theme.accent) + .fg(theme.bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + ListItem::new(line).style(style) + }) + .collect(); + let list = List::new(items).block(block); + f.render_widget(list, area); +} + +fn draw_charts(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" Usage / history ", theme.title)); + let inner = block.inner(area); + f.render_widget(block, area); + + let o = state.outputs.get(state.selected); + let m = o + .map(|o| NormalizedMetricsMapper::from_output(o)) + .unwrap_or_default(); + let vals = state + .rings + .get(state.selected) + .map(|r| r.sparkline_values()) + .unwrap_or_else(|| vec![0; 32]); + + let scroll = state.chart_scroll.min(vals.len().saturating_sub(1)); + let window: Vec = vals.into_iter().skip(scroll).take(32).collect(); + let mut pts: Vec<(f64, f64)> = window + .iter() + .enumerate() + .map(|(i, &v)| (i as f64, v as f64)) + .collect(); + if pts.len() < 2 { + pts = vec![(0., 0.), (1., 0.)]; + } + + let datasets = vec![Dataset::default() + .name("primary %") + .marker(symbols::Marker::Braille) + .graph_type(GraphType::Line) + .style(Style::default().fg(theme.accent)) + .data(&pts)]; + + let chart = Chart::new(datasets) + .block(Block::default().borders(Borders::NONE)) + .x_axis( + Axis::default() + .bounds([0.0, 31.0]) + .labels(vec![Span::raw("t"), Span::raw("scroll ←/→")]), + ) + .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![ + Span::raw("0"), + Span::raw("50"), + Span::raw("100"), + ])); + + let meta = format!( + "primary {:.0}% | in {:?} out {:?} | cost {:?} | cache {:?}", + m.primary_percent, m.input_tokens, m.output_tokens, m.cost, m.cache_hits + ); + let p = Paragraph::new(meta) + .style(Style::default().fg(theme.muted)) + .wrap(Wrap { trim: true }); + + let sub = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(4), Constraint::Length(2)].as_ref()) + .split(inner); + f.render_widget(chart, sub[0]); + f.render_widget(p, sub[1]); +} + +fn draw_detail_table(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme, scroll: u16) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" Metrics ", theme.title)); + let inner = block.inner(area); + let o = state.outputs.get(state.selected); + let text = o + .map(|o| format_plugin_lines(o)) + .unwrap_or_else(|| "(none)".into()); + let p = Paragraph::new(text) + .style(Style::default().fg(theme.fg)) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + f.render_widget(block, area); + f.render_widget(p, inner); +} + +fn format_plugin_lines(o: &PluginOutput) -> String { + use openusage_core::plugin_engine::runtime::{MetricLine, ProgressFormat}; + let mut s = String::new(); + s.push_str(&format!("{} ({})\n", o.display_name, o.provider_id)); + if let Some(ref pl) = o.plan { + s.push_str(&format!("Plan: {pl}\n")); + } + s.push('\n'); + for line in &o.lines { + match line { + MetricLine::Text { + label, + value, + subtitle, + .. + } => { + s.push_str(label); + s.push_str(": "); + s.push_str(value); + if let Some(sub) = subtitle { + s.push_str(&format!(" ({sub})")); + } + s.push('\n'); + } + MetricLine::Progress { + label, + used, + limit, + format, + resets_at, + .. + } => { + let pct = if *limit > 0.0 { + (*used / *limit) * 100.0 + } else { + 0.0 + }; + let v = match format { + ProgressFormat::Percent => format!("{:.1}% ({:.0}/{:.0})", pct, used, limit), + ProgressFormat::Dollars => format!("${:.2} / ${:.2}", used, limit), + ProgressFormat::Count { suffix } => { + format!("{:.0} / {:.0} {}", used, limit, suffix) + } + }; + s.push_str(label); + s.push_str(": "); + s.push_str(&v); + if let Some(r) = resets_at { + let rel = format_resets_at_for_display(r); + if !rel.is_empty() { + s.push_str(&format!(" · {rel}")); + } + } + s.push('\n'); + } + MetricLine::Badge { + label, + text, + subtitle, + .. + } => { + s.push_str(label); + s.push_str(": "); + s.push_str(text); + if let Some(sub) = subtitle { + s.push_str(&format!(" ({sub})")); + } + s.push('\n'); + } + MetricLine::BarChart { .. } => {} + } + } + s +} + +fn draw_status_bar(f: &mut Frame, area: Rect, state: &AppState, theme: &Theme) { + let stale = state + .stale_secs(state.selected) + .map(|s| format!("Stale {s}s")) + .unwrap_or_else(|| "Fresh".into()); + let spin = ["|", "/", "-", "\\"][state.spinner_frame as usize % 4]; + let probe = if let Some((c, t)) = state.probe_progress { + format!("Probing {c}/{t} {spin} ") + } else if state.refreshing { + format!("Refreshing… {spin} ") + } else { + "Idle ".into() + }; + let extra = state.status_msg.as_deref().unwrap_or(""); + let low = if state.config.low_power_mode { + "[LOW-POWER] " + } else { + "" + }; + let line = format!( + "openusage-cli | {low}{stale} | {probe}| probe {}s | {extra}↑↓jk r refresh p low-power ? help q quit", + state.config.effective_probe_sec(), + ); + let p = Paragraph::new(Span::styled( + line, + Style::default().fg(theme.muted).bg(theme.bg), + )) + .style(Style::default().bg(theme.bg)); + f.render_widget(p, area); +} + +fn draw_help_modal(f: &mut Frame, r: Rect, theme: &Theme) { + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)); + let area = centered_rect(72, 55, r); + f.render_widget(Clear, area); + f.render_widget(block.clone(), area); + let inner = block.inner(area); + let help = Paragraph::new( + "q Quit\n\ + Ctrl+C Quit\n\ + Esc Close help\n\ + ? This help\n\ + Tab Cycle pane focus\n\ + ↑↓ jk Move provider\n\ + ←→ Chart history scroll\n\ + r Refresh all providers\n\ + p Toggle low-power (saved to config)\n\ + Mouse Resize panes (when enabled)\n", + ) + .style(Style::default().fg(theme.fg)) + .wrap(Wrap { trim: true }); + f.render_widget(help, inner); +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} diff --git a/crates/openusage-cli/src/tui/mod.rs b/crates/openusage-cli/src/tui/mod.rs new file mode 100644 index 000000000..70e3d915f --- /dev/null +++ b/crates/openusage-cli/src/tui/mod.rs @@ -0,0 +1,12 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Ratatui dashboard — btop-style OpenUsage TUI. + +mod app; +mod picker; +mod platform; +mod state; +pub mod theme; +pub mod view_model; + +pub use app::run; +pub use platform::ignore_sigtstp; diff --git a/crates/openusage-cli/src/tui/picker.rs b/crates/openusage-cli/src/tui/picker.rs new file mode 100644 index 000000000..d3a8432fc --- /dev/null +++ b/crates/openusage-cli/src/tui/picker.rs @@ -0,0 +1,228 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Interactive checkbox list: choose which providers to load before the dashboard. + +use std::io::{stdout, Stdout}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use openusage_core::plugin_engine::manifest::LoadedPlugin; +use ratatui::layout::{Alignment, Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::style::Modifier; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph}; + +use crate::config::CliConfig; +use crate::tui::theme::{Theme, ThemePreset}; + +fn restore_picker_terminal(terminal: &mut Terminal>) -> Result<()> { + let t = terminal.backend_mut(); + let _ = execute!(t, LeaveAlternateScreen, Show); + disable_raw_mode().context("disable_raw_mode picker")?; + Ok(()) +} + +/// Arrow keys move, Enter toggles `[x]` on a provider row; last row is **Continue** (Enter to confirm). +/// Returns the subset of `candidate_indices` that stayed checked (at least one required). +pub fn run_provider_picker( + plugins: &Arc>, + candidate_indices: &[usize], + shutdown: &Arc, + config: &CliConfig, +) -> Result> { + let len_plugins = candidate_indices.len(); + if len_plugins == 0 { + return Ok(vec![]); + } + + let continue_row = len_plugins; // cursor index for "Continue" + let mut selected: Vec = vec![true; len_plugins]; + let mut cursor: usize = 0; + // First visible provider index when the list is taller than the terminal. + let mut scroll_top: usize = 0; + let mut status_msg: Option = None; + + enable_raw_mode().context("enable_raw_mode picker")?; + let mut out = stdout(); + execute!(out, EnterAlternateScreen, Hide).context("enter alt screen picker")?; + let backend = CrosstermBackend::new(out); + let mut terminal = Terminal::new(backend)?; + let theme = Theme::from_preset(ThemePreset::parse(&config.theme)); + + let run_result = (|| -> Result> { + loop { + if shutdown.load(Ordering::SeqCst) { + anyhow::bail!("Interrupted"); + } + + terminal.draw(|f| { + let size = f.area(); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled(" OpenUsage — select providers ", theme.title)); + let inner = block.inner(size); + f.render_widget(block, size); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(0), // list fills all space down to Continue + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(inner); + + let help = Paragraph::new( + "↑↓ move · Enter / Space toggle · Enter on Continue · q cancel", + ) + .style(Style::default().fg(theme.muted)) + .alignment(Alignment::Center); + f.render_widget(help, chunks[0]); + + // Rows that fit in the list block (borders + one title line, like the final block). + let visible = { + let b = Block::default() + .borders(Borders::ALL) + .title(Line::from("-")) + .inner(chunks[1]); + (b.height as usize).max(1) + }; + + if len_plugins <= visible { + scroll_top = 0; + } else if cursor < len_plugins { + if cursor < scroll_top { + scroll_top = cursor; + } + if cursor >= scroll_top + visible { + scroll_top = cursor + 1 - visible; + } + let max_scroll = len_plugins - visible; + scroll_top = scroll_top.min(max_scroll); + } + + let window_end = (scroll_top + visible).min(len_plugins); + + let title_line = if len_plugins > visible { + Line::from(vec![ + Span::styled(" Providers ", theme.title), + Span::styled( + format!( + " (rows {}–{} of {}) ", + scroll_top + 1, + window_end, + len_plugins + ), + Style::default().fg(theme.muted), + ), + ]) + } else { + Line::from(Span::styled(" Providers ", theme.title)) + }; + + let list_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .title(title_line); + let list_inner = list_block.inner(chunks[1]); + f.render_widget(list_block, chunks[1]); + let items: Vec = (scroll_top..window_end) + .map(|i| { + let idx = candidate_indices[i]; + let p = &plugins[idx]; + let chk = if selected[i] { "[x]" } else { "[ ]" }; + let line = format!(" {chk} {} ({})", p.manifest.name, p.manifest.id); + let style = if cursor == i { + Style::default() + .bg(theme.accent) + .fg(theme.bg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.fg) + }; + ListItem::new(line).style(style) + }) + .collect(); + let list = List::new(items); + f.render_widget(list, list_inner); + + let cont_line = if cursor == continue_row { + " ▸ Continue " + } else { + " Continue " + }; + let cont_style = if cursor == continue_row { + Style::default() + .fg(theme.bg) + .bg(theme.good) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.muted) + }; + let cont_p = Paragraph::new(cont_line) + .style(cont_style) + .alignment(Alignment::Center); + f.render_widget(cont_p, chunks[2]); + + let st = status_msg.as_deref().unwrap_or(""); + let foot = Paragraph::new(st) + .style(Style::default().fg(theme.warn)) + .alignment(Alignment::Center); + f.render_widget(foot, chunks[3]); + })?; + + if !event::poll(Duration::from_millis(75))? { + continue; + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { + anyhow::bail!("Provider selection cancelled (q). Use --no-picker to skip this screen."); + } + KeyCode::Up => { + cursor = cursor.saturating_sub(1); + status_msg = None; + } + KeyCode::Down => { + cursor = (cursor + 1).min(continue_row); + status_msg = None; + } + KeyCode::Enter | KeyCode::Char(' ') => { + if cursor < len_plugins { + selected[cursor] = !selected[cursor]; + status_msg = None; + } else { + let mut out_idx = Vec::new(); + for (i, &on) in selected.iter().enumerate() { + if on { + out_idx.push(candidate_indices[i]); + } + } + if out_idx.is_empty() { + status_msg = + Some("Check at least one provider, or press q to quit.".into()); + } else { + return Ok(out_idx); + } + } + } + _ => {} + }, + _ => {} + } + } + })(); + + restore_picker_terminal(&mut terminal)?; + run_result +} diff --git a/crates/openusage-cli/src/tui/platform.rs b/crates/openusage-cli/src/tui/platform.rs new file mode 100644 index 000000000..c8c710983 --- /dev/null +++ b/crates/openusage-cli/src/tui/platform.rs @@ -0,0 +1,71 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Platform-specific helpers (signals, etc.). + +/// Ignore signals that make **bash** print `[1]+ Stopped` (job suspended). +/// +/// - **SIGTSTP** — Ctrl+Z +/// - **SIGTTIN** / **SIGTTOU** — terminal I/O while job control thinks the process is in the +/// “wrong” state (common with **IDE-integrated terminals** and lots of stderr during probes) +/// +/// Call again after long-running work (e.g. each plugin probe) in case a child resets handlers. +pub fn ignore_sigtstp() { + #[cfg(unix)] + unsafe { + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = libc::SIG_IGN; + libc::sigemptyset(&mut sa.sa_mask); + sa.sa_flags = 0; + for sig in [libc::SIGTSTP, libc::SIGTTIN, libc::SIGTTOU] { + let _ = libc::sigaction(sig, &sa, std::ptr::null_mut()); + } + } +} + +/// True when this process was spawned by `cargo` (e.g. `cargo run`). +/// +/// The shell’s **job** is usually the `cargo` parent, not `openusage-cli`. If something +/// suspends that job (Ctrl+Z, or odd terminal/IDE behavior), bash prints `[1]+ Stopped cargo run …` +/// even though you never “quit” the TUI — **run `./target/debug/openusage-cli`** instead. +#[cfg(target_os = "linux")] +pub fn parent_process_is_cargo() -> bool { + parent_comm_linux().map(|n| n == "cargo").unwrap_or(false) +} + +/// macOS, FreeBSD, etc.: use sysinfo (same crate as the dashboard) so we detect `cargo run` parents. +#[cfg(all(unix, not(target_os = "linux")))] +pub fn parent_process_is_cargo() -> bool { + use std::os::unix::process::parent_id; + + let ppid = parent_id(); + if ppid == 0 { + return false; + } + let pid = sysinfo::Pid::from_u32(ppid); + let mut sys = sysinfo::System::new(); + sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid) + .map(|p| { + let name = p.name().to_string_lossy(); + name.eq_ignore_ascii_case("cargo") || name.ends_with("/cargo") + }) + .unwrap_or(false) +} + +#[cfg(not(unix))] +pub fn parent_process_is_cargo() -> bool { + false +} + +#[cfg(target_os = "linux")] +fn parent_comm_linux() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + let ppid: u32 = status + .lines() + .find(|l| l.starts_with("PPid:"))? + .split_whitespace() + .nth(1)? + .parse() + .ok()?; + let comm = std::fs::read_to_string(format!("/proc/{ppid}/comm")).ok()?; + Some(comm.trim().to_string()) +} diff --git a/crates/openusage-cli/src/tui/state.rs b/crates/openusage-cli/src/tui/state.rs new file mode 100644 index 000000000..0b575df67 --- /dev/null +++ b/crates/openusage-cli/src/tui/state.rs @@ -0,0 +1,117 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Application state: probe scheduling, selection, UI. + +use openusage_core::plugin_engine::runtime::PluginOutput; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use crate::config::CliConfig; +use crate::history::PercentRing; +use crate::tui::view_model::NormalizedMetricsMapper; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pane { + List, + Detail, + Spark, +} + +pub struct AppState { + pub config_path: PathBuf, + pub config: CliConfig, + pub config_path_mtime: Option, + pub selected: usize, + pub pane: Pane, + pub help_open: bool, + /// First full probe batch finished (show full dashboard vs loading screen). + pub initial_load_complete: bool, + pub last_config_poll: Instant, + pub outputs: Vec, + pub last_probe: Vec>, + pub rr_index: usize, + pub last_full_probe: Instant, + pub probe_busy: bool, + pub refreshing: bool, + pub status_msg: Option, + pub rings: Vec, + /// Background probe batch progress (current, total), if known. + pub probe_progress: Option<(usize, usize)>, + /// Spinner frame for status line while probing. + pub spinner_frame: u8, + /// Horizontal scroll into chart history (sample index offset). + pub chart_scroll: usize, + /// Throttled host metrics (sysinfo). + pub host_cpu_pct: f32, + pub host_mem_used_mb: u64, + pub host_mem_total_mb: u64, + pub last_sysinfo: Instant, +} + +impl AppState { + pub fn new( + config_path: PathBuf, + config: CliConfig, + initial_outputs: Vec, + ) -> Self { + let n = initial_outputs.len(); + let now = Instant::now(); + let cap = config.history_capacity; + let mut rings = Vec::with_capacity(n); + for o in &initial_outputs { + let mut r = PercentRing::new(cap); + let m = NormalizedMetricsMapper::from_output(o); + r.push_percent(m.primary_percent); + rings.push(r); + } + let meta_mtime = config_path.metadata().ok().and_then(|m| m.modified().ok()); + Self { + config_path, + config, + config_path_mtime: meta_mtime, + selected: 0, + pane: Pane::List, + help_open: false, + initial_load_complete: false, + last_config_poll: now, + last_probe: vec![Some(now); n], + outputs: initial_outputs, + rings, + rr_index: 0, + last_full_probe: now, + probe_busy: false, + refreshing: false, + status_msg: None, + probe_progress: None, + spinner_frame: 0, + chart_scroll: 0, + host_cpu_pct: 0.0, + host_mem_used_mb: 0, + host_mem_total_mb: 1, + last_sysinfo: now - Duration::from_secs(10), + } + } + + pub fn effective_probe_interval(&self) -> Duration { + Duration::from_secs(self.config.effective_probe_sec()) + } + + pub fn ui_tick(&self) -> Duration { + Duration::from_millis(self.config.ui_tick_ms()) + } + + pub fn stale_secs(&self, idx: usize) -> Option { + self.last_probe + .get(idx) + .copied() + .flatten() + .map(|t| t.elapsed().as_secs()) + } + + pub fn cycle_pane(&mut self) { + self.pane = match self.pane { + Pane::List => Pane::Detail, + Pane::Detail => Pane::Spark, + Pane::Spark => Pane::List, + }; + } +} diff --git a/crates/openusage-cli/src/tui/theme.rs b/crates/openusage-cli/src/tui/theme.rs new file mode 100644 index 000000000..4856c827a --- /dev/null +++ b/crates/openusage-cli/src/tui/theme.rs @@ -0,0 +1,95 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Theme presets (Gruvbox-inspired dark / light). + +use ratatui::style::{Color, Modifier, Style}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemePreset { + Dark, + Light, + /// Colorful accents (btop-inspired). + BtopRainbow, + /// Follow system — default to dark for now. + Auto, +} + +impl ThemePreset { + pub fn parse(s: &str) -> Self { + match s.to_lowercase().as_str() { + "light" => ThemePreset::Light, + "btop-rainbow" | "btop_rainbow" | "rainbow" => ThemePreset::BtopRainbow, + "auto" => ThemePreset::Auto, + _ => ThemePreset::Dark, + } + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] // palette reserved for charts / alerts / help styling +pub struct Theme { + pub bg: Color, + pub fg: Color, + pub muted: Color, + pub accent: Color, + pub good: Color, + pub warn: Color, + pub bad: Color, + pub border: Color, + pub title: Style, + pub help_key: Style, +} + +impl Theme { + pub fn from_preset(p: ThemePreset) -> Self { + match p { + ThemePreset::Auto | ThemePreset::Dark => Self { + bg: Color::Rgb(40, 40, 40), + fg: Color::Rgb(235, 219, 178), + muted: Color::Rgb(146, 131, 116), + accent: Color::Rgb(131, 165, 152), + good: Color::Rgb(184, 187, 38), + warn: Color::Rgb(250, 189, 47), + bad: Color::Rgb(251, 73, 52), + border: Color::Rgb(80, 73, 69), + title: Style::default() + .fg(Color::Rgb(251, 241, 199)) + .add_modifier(Modifier::BOLD), + help_key: Style::default() + .fg(Color::Rgb(214, 93, 14)) + .add_modifier(Modifier::BOLD), + }, + ThemePreset::BtopRainbow => Self { + bg: Color::Rgb(26, 27, 38), + fg: Color::Rgb(220, 220, 230), + muted: Color::Rgb(147, 153, 178), + accent: Color::Rgb(137, 180, 250), + good: Color::Rgb(166, 227, 161), + warn: Color::Rgb(249, 226, 175), + bad: Color::Rgb(243, 139, 168), + border: Color::Rgb(88, 91, 112), + title: Style::default() + .fg(Color::Rgb(203, 166, 247)) + .add_modifier(Modifier::BOLD), + help_key: Style::default() + .fg(Color::Rgb(250, 179, 135)) + .add_modifier(Modifier::BOLD), + }, + ThemePreset::Light => Self { + bg: Color::Rgb(251, 241, 199), + fg: Color::Rgb(60, 56, 54), + muted: Color::Rgb(102, 92, 84), + accent: Color::Rgb(69, 133, 136), + good: Color::Rgb(121, 116, 14), + warn: Color::Rgb(181, 118, 20), + bad: Color::Rgb(204, 36, 29), + border: Color::Rgb(189, 174, 147), + title: Style::default() + .fg(Color::Rgb(40, 40, 40)) + .add_modifier(Modifier::BOLD), + help_key: Style::default() + .fg(Color::Rgb(175, 58, 3)) + .add_modifier(Modifier::BOLD), + }, + } + } +} diff --git a/crates/openusage-cli/src/tui/view_model.rs b/crates/openusage-cli/src/tui/view_model.rs new file mode 100644 index 000000000..d72931be6 --- /dev/null +++ b/crates/openusage-cli/src/tui/view_model.rs @@ -0,0 +1,4 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Re-export usage normalization from `openusage-core` (shared with desktop history). + +pub use openusage_core::usage_metrics::{NormalizedMetrics, NormalizedMetricsMapper}; diff --git a/crates/openusage-core/Cargo.toml b/crates/openusage-core/Cargo.toml new file mode 100644 index 000000000..a55ba26ec --- /dev/null +++ b/crates/openusage-core/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "openusage-core" +version = "0.6.37" +description = "Shared plugin engine for OpenUsage (GUI + CLI)" +edition = "2024" + +[lib] +name = "openusage_core" +path = "src/lib.rs" + +[dependencies] +aes-gcm = "0.10.3" +base64 = "0.22" +log = "0.4" +regex-lite = "0.1.9" +reqwest = { version = "0.13.3", features = ["blocking", "json", "socks"] } +rquickjs = { version = "0.12", features = ["bindgen"] } +rusqlite = { version = "0.32", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +chrono = "0.4" +csv = "1.3" +sha2 = "0.11" +time = { version = "0.3.47", features = ["formatting", "parsing"] } +dirs = "6" +keyring = { version = "3", default-features = false, features = ["apple-native", "windows-native", "sync-secret-service"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +users = "0.11" + +[dev-dependencies] +tempfile = "3" +serial_test = "3.5" diff --git a/crates/openusage-core/data/pricing_litellm_snapshot.json b/crates/openusage-core/data/pricing_litellm_snapshot.json new file mode 100644 index 000000000..eb6fd0600 --- /dev/null +++ b/crates/openusage-core/data/pricing_litellm_snapshot.json @@ -0,0 +1 @@ +{"models":{"ai21.j2-mid-v1":{"cr":1.25,"cw":12.5,"i":12.5,"o":12.5},"ai21.j2-ultra-v1":{"cr":1.88,"cw":18.8,"i":18.8,"o":18.8},"ai21.jamba-1-5-large-v1:0":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"ai21.jamba-1-5-mini-v1:0":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"ai21.jamba-instruct-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.7},"amazon-nova/nova-lite-v1":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"amazon-nova/nova-micro-v1":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.14},"amazon-nova/nova-premier-v1":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":12.5},"amazon-nova/nova-pro-v1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":3.1999999999999997},"amazon.nova-2-lite-v1:0":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.5},"amazon.nova-2-multimodal-embeddings-v1:0":{"cr":0.013500000000000002,"cw":0.135,"i":0.135,"o":0.0},"amazon.nova-2-pro-preview-20251202-v1:0":{"cr":0.546875,"cw":2.1875,"i":2.1875,"o":17.5},"amazon.nova-lite-v1:0":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"amazon.nova-micro-v1:0":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.14},"amazon.nova-pro-v1:0":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":3.1999999999999997},"amazon.rerank-v1:0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"amazon.titan-embed-g1-text-02":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"amazon.titan-embed-image-v1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.0},"amazon.titan-embed-text-v1":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"amazon.titan-embed-text-v2:0":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"amazon.titan-text-express-v1":{"cr":0.13,"cw":1.3,"i":1.3,"o":1.7},"amazon.titan-text-lite-v1":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.39999999999999997},"amazon.titan-text-premier-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"anthropic.claude-3-5-haiku-20241022-v1:0":{"cr":0.08,"cw":1.0,"i":0.7999999999999999,"o":4.0},"anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":30.0},"anthropic.claude-3-5-sonnet-20241022-v2:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":30.0},"anthropic.claude-3-7-sonnet-20240620-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"anthropic.claude-3-7-sonnet-20250219-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.024999999999999998,"cw":0.3125,"i":0.25,"o":1.25},"anthropic.claude-3-opus-20240229-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"anthropic.claude-3-sonnet-20240229-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"anthropic.claude-fable-5":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"anthropic.claude-haiku-4-5@20251001":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"anthropic.claude-instant-v1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"anthropic.claude-mythos-preview":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"anthropic.claude-opus-4-1-20250805-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"anthropic.claude-opus-4-20250514-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"anthropic.claude-opus-4-5-20251101-v1:0":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"anthropic.claude-opus-4-6-v1":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"anthropic.claude-opus-4-7":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"anthropic.claude-opus-4-8":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"anthropic.claude-sonnet-4-20250514-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"anthropic.claude-sonnet-4-6":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"anthropic.claude-sonnet-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"anthropic.claude-v1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"anthropic.claude-v2:1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"anyscale/HuggingFaceH4/zephyr-7b-beta":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"anyscale/codellama/CodeLlama-34b-Instruct-hf":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"anyscale/codellama/CodeLlama-70b-Instruct-hf":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"anyscale/google/gemma-7b-it":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"anyscale/meta-llama/Llama-2-13b-chat-hf":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"anyscale/meta-llama/Llama-2-70b-chat-hf":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"anyscale/meta-llama/Llama-2-7b-chat-hf":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"anyscale/meta-llama/Meta-Llama-3-70B-Instruct":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"anyscale/meta-llama/Meta-Llama-3-8B-Instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"anyscale/mistralai/Mistral-7B-Instruct-v0.1":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"apac.amazon.nova-2-lite-v1:0":{"cr":0.0825,"cw":0.33,"i":0.33,"o":2.75},"apac.amazon.nova-2-pro-preview-20251202-v1:0":{"cr":0.546875,"cw":2.1875,"i":2.1875,"o":17.5},"apac.amazon.nova-lite-v1:0":{"cr":0.0063,"cw":0.063,"i":0.063,"o":0.252},"apac.amazon.nova-micro-v1:0":{"cr":0.0037,"cw":0.037,"i":0.037,"o":0.148},"apac.amazon.nova-pro-v1:0":{"cr":0.084,"cw":0.84,"i":0.84,"o":3.36},"apac.anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"apac.anthropic.claude-3-5-sonnet-20241022-v2:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"apac.anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.024999999999999998,"cw":0.3125,"i":0.25,"o":1.25},"apac.anthropic.claude-3-sonnet-20240229-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"apac.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.11,"cw":1.375,"i":1.1,"o":5.5},"apac.anthropic.claude-sonnet-4-20250514-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"au.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.11,"cw":1.375,"i":1.1,"o":5.5},"au.anthropic.claude-opus-4-6-v1":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"au.anthropic.claude-opus-4-7":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"au.anthropic.claude-opus-4-8":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"au.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.33,"cra":0.66,"cw":4.125,"cwa":8.25,"i":3.3000000000000003,"ia":6.6000000000000005,"o":16.5,"oa":24.75},"au.anthropic.claude-sonnet-4-6":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"au.anthropic.claude-sonnet-5":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"azure/ada":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"azure/codex-mini":{"cr":0.375,"cw":1.5,"i":1.5,"o":6.0},"azure/command-r-plus":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"azure/computer-use-preview":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":12.0},"azure/eu/gpt-4o-2024-08-06":{"cr":1.375,"cw":2.75,"i":2.75,"o":11.0},"azure/eu/gpt-4o-2024-11-20":{"cr":0.275,"cw":1.38,"i":2.75,"o":11.0},"azure/eu/gpt-4o-mini-2024-07-18":{"cr":0.083,"cw":0.165,"i":0.165,"o":0.66},"azure/eu/gpt-4o-mini-realtime-preview-2024-12-17":{"cr":0.33,"cw":0.66,"i":0.66,"o":2.64},"azure/eu/gpt-4o-realtime-preview-2024-10-01":{"cr":2.75,"cw":5.5,"i":5.5,"o":22.0},"azure/eu/gpt-4o-realtime-preview-2024-12-17":{"cr":2.75,"cw":5.5,"i":5.5,"o":22.0},"azure/eu/gpt-5-2025-08-07":{"cr":0.1375,"cw":1.375,"i":1.375,"o":11.0},"azure/eu/gpt-5-mini-2025-08-07":{"cr":0.0275,"cw":0.275,"i":0.275,"o":2.2},"azure/eu/gpt-5-nano-2025-08-07":{"cr":0.0055,"cw":0.055,"i":0.055,"o":0.44},"azure/eu/gpt-5.1":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/eu/gpt-5.1-chat":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/eu/gpt-5.1-codex":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/eu/gpt-5.1-codex-mini":{"cr":0.028,"cw":0.275,"i":0.275,"o":2.2},"azure/eu/o1-2024-12-17":{"cr":8.25,"cw":16.5,"i":16.5,"o":66.0},"azure/eu/o1-mini-2024-09-12":{"cr":0.605,"cw":1.21,"i":1.21,"o":4.84},"azure/eu/o1-preview-2024-09-12":{"cr":8.25,"cw":16.5,"i":16.5,"o":66.0},"azure/eu/o3-mini-2025-01-31":{"cr":0.605,"cw":1.21,"i":1.21,"o":4.84},"azure/global-standard/gpt-4o-2024-08-06":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/global-standard/gpt-4o-2024-11-20":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/global-standard/gpt-4o-mini":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"azure/global/gpt-4o-2024-08-06":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/global/gpt-4o-2024-11-20":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/global/gpt-5.1":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/global/gpt-5.1-chat":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/global/gpt-5.1-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/global/gpt-5.1-codex-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"azure/gpt-3.5-turbo":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"azure/gpt-3.5-turbo-0125":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"azure/gpt-3.5-turbo-instruct-0914":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"azure/gpt-35-turbo":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"azure/gpt-35-turbo-0125":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"azure/gpt-35-turbo-1106":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":2.0},"azure/gpt-35-turbo-16k":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.0},"azure/gpt-35-turbo-16k-0613":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.0},"azure/gpt-35-turbo-instruct":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"azure/gpt-35-turbo-instruct-0914":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"azure/gpt-4":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"azure/gpt-4-0125-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"azure/gpt-4-0613":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"azure/gpt-4-1106-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"azure/gpt-4-32k":{"cr":6.0,"cw":60.0,"i":60.0,"o":120.0},"azure/gpt-4-32k-0613":{"cr":6.0,"cw":60.0,"i":60.0,"o":120.0},"azure/gpt-4-turbo":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"azure/gpt-4-turbo-2024-04-09":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"azure/gpt-4-turbo-vision-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"azure/gpt-4.1":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"azure/gpt-4.1-2025-04-14":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"azure/gpt-4.1-mini":{"cr":0.09999999999999999,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"azure/gpt-4.1-mini-2025-04-14":{"cr":0.09999999999999999,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"azure/gpt-4.1-nano":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"azure/gpt-4.1-nano-2025-04-14":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"azure/gpt-4.5-preview":{"cr":37.5,"cw":75.0,"i":75.0,"o":150.0},"azure/gpt-4o":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-2024-05-13":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"azure/gpt-4o-2024-08-06":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-2024-11-20":{"cr":1.25,"cw":2.75,"i":2.75,"o":11.0},"azure/gpt-4o-audio-preview-2024-12-17":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-mini":{"cr":0.075,"cw":0.165,"i":0.165,"o":0.66},"azure/gpt-4o-mini-2024-07-18":{"cr":0.075,"cw":0.165,"i":0.165,"o":0.66},"azure/gpt-4o-mini-audio-preview-2024-12-17":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-mini-realtime-preview-2024-12-17":{"cr":0.3,"cw":0.6,"i":0.6,"o":2.4},"azure/gpt-4o-mini-transcribe":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":5.0},"azure/gpt-4o-mini-tts":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-realtime-preview-2024-10-01":{"cr":2.5,"cw":5.0,"i":5.0,"o":20.0},"azure/gpt-4o-realtime-preview-2024-12-17":{"cr":2.5,"cw":5.0,"i":5.0,"o":20.0},"azure/gpt-4o-transcribe":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-4o-transcribe-diarize":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-5":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5-2025-08-07":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5-chat":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5-chat-latest":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"azure/gpt-5-mini-2025-08-07":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"azure/gpt-5-nano":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"azure/gpt-5-nano-2025-08-07":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"azure/gpt-5-pro":{"cr":1.5,"cw":15.0,"i":15.0,"o":120.0},"azure/gpt-5.1":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-2025-11-13":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-chat":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-chat-2025-11-13":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-codex-2025-11-13":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-codex-max":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"azure/gpt-5.1-codex-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"azure/gpt-5.1-codex-mini-2025-11-13":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"azure/gpt-5.2":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.2-2025-12-11":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.2-chat":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.2-chat-2025-12-11":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.2-codex":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.2-pro":{"cr":2.0999999999999996,"cw":21.0,"i":21.0,"o":168.0},"azure/gpt-5.2-pro-2025-12-11":{"cr":2.0999999999999996,"cw":21.0,"i":21.0,"o":168.0},"azure/gpt-5.3-chat":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.3-codex":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"azure/gpt-5.4":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"azure/gpt-5.4-2026-03-05":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"azure/gpt-5.4-mini":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"azure/gpt-5.4-mini-2026-03-17":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"azure/gpt-5.4-nano":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"azure/gpt-5.4-nano-2026-03-17":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"azure/gpt-5.4-pro":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure/gpt-5.4-pro-2026-03-05":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure/gpt-5.5":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"azure/gpt-5.5-2026-04-23":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"azure/gpt-5.5-pro":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure/gpt-5.5-pro-2026-04-23":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure/gpt-audio-1.5-2026-02-23":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-audio-2025-08-28":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"azure/gpt-audio-mini-2025-10-06":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"azure/gpt-image-2":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"azure/gpt-image-2-2026-04-21":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"azure/gpt-realtime-1.5-2026-02-23":{"cr":4.0,"cw":4.0,"i":4.0,"o":16.0},"azure/gpt-realtime-2025-08-28":{"cr":4.0,"cw":4.0,"i":4.0,"o":16.0},"azure/gpt-realtime-mini-2025-10-06":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"azure/mistral-large-2402":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"azure/mistral-large-latest":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"azure/o1":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"azure/o1-2024-12-17":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"azure/o1-mini":{"cr":0.605,"cw":1.21,"i":1.21,"o":4.84},"azure/o1-mini-2024-09-12":{"cr":0.55,"cw":1.1,"i":1.1,"o":4.4},"azure/o1-preview":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"azure/o1-preview-2024-09-12":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"azure/o3":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"azure/o3-2025-04-16":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"azure/o3-deep-research":{"cr":2.5,"cw":10.0,"i":10.0,"o":40.0},"azure/o3-mini":{"cr":0.55,"cw":1.1,"i":1.1,"o":4.4},"azure/o3-mini-2025-01-31":{"cr":0.55,"cw":1.1,"i":1.1,"o":4.4},"azure/o3-pro":{"cr":2.0000000000000004,"cw":20.0,"i":20.0,"o":80.0},"azure/o3-pro-2025-06-10":{"cr":2.0000000000000004,"cw":20.0,"i":20.0,"o":80.0},"azure/o4-mini":{"cr":0.275,"cw":1.1,"i":1.1,"o":4.4},"azure/o4-mini-2025-04-16":{"cr":0.275,"cw":1.1,"i":1.1,"o":4.4},"azure/text-embedding-3-large":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.0},"azure/text-embedding-3-small":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"azure/text-embedding-ada-002":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"azure/us/gpt-4.1-2025-04-14":{"cr":0.55,"cw":2.2,"i":2.2,"o":8.8},"azure/us/gpt-4.1-mini-2025-04-14":{"cr":0.11,"cw":0.44,"i":0.44,"o":1.76},"azure/us/gpt-4.1-nano-2025-04-14":{"cr":0.024999999999999998,"cw":0.11,"i":0.11,"o":0.44},"azure/us/gpt-4o-2024-08-06":{"cr":1.375,"cw":2.75,"i":2.75,"o":11.0},"azure/us/gpt-4o-2024-11-20":{"cr":0.275,"cw":1.38,"i":2.75,"o":11.0},"azure/us/gpt-4o-mini-2024-07-18":{"cr":0.083,"cw":0.165,"i":0.165,"o":0.66},"azure/us/gpt-4o-mini-realtime-preview-2024-12-17":{"cr":0.33,"cw":0.66,"i":0.66,"o":2.64},"azure/us/gpt-4o-realtime-preview-2024-10-01":{"cr":2.75,"cw":5.5,"i":5.5,"o":22.0},"azure/us/gpt-4o-realtime-preview-2024-12-17":{"cr":2.75,"cw":5.5,"i":5.5,"o":22.0},"azure/us/gpt-5-2025-08-07":{"cr":0.1375,"cw":1.375,"i":1.375,"o":11.0},"azure/us/gpt-5-mini-2025-08-07":{"cr":0.0275,"cw":0.275,"i":0.275,"o":2.2},"azure/us/gpt-5-nano-2025-08-07":{"cr":0.0055,"cw":0.055,"i":0.055,"o":0.44},"azure/us/gpt-5.1":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/us/gpt-5.1-chat":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/us/gpt-5.1-codex":{"cr":0.14,"cw":1.38,"i":1.38,"o":11.0},"azure/us/gpt-5.1-codex-mini":{"cr":0.028,"cw":0.275,"i":0.275,"o":2.2},"azure/us/o1-2024-12-17":{"cr":8.25,"cw":16.5,"i":16.5,"o":66.0},"azure/us/o1-mini-2024-09-12":{"cr":0.605,"cw":1.21,"i":1.21,"o":4.84},"azure/us/o1-preview-2024-09-12":{"cr":8.25,"cw":16.5,"i":16.5,"o":66.0},"azure/us/o3-2025-04-16":{"cr":0.55,"cw":2.2,"i":2.2,"o":8.8},"azure/us/o3-mini-2025-01-31":{"cr":0.605,"cw":1.21,"i":1.21,"o":4.84},"azure/us/o4-mini-2025-04-16":{"cr":0.31,"cw":1.21,"i":1.21,"o":4.84},"azure_ai/Cohere-embed-v3-english":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"azure_ai/Cohere-embed-v3-multilingual":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"azure_ai/Llama-3.2-11B-Vision-Instruct":{"cr":0.037,"cw":0.37,"i":0.37,"o":0.37},"azure_ai/Llama-3.2-90B-Vision-Instruct":{"cr":0.204,"cw":2.04,"i":2.04,"o":2.04},"azure_ai/Llama-3.3-70B-Instruct":{"cr":0.071,"cw":0.71,"i":0.71,"o":0.71},"azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8":{"cr":0.14100000000000001,"cw":1.4100000000000001,"i":1.4100000000000001,"o":0.35},"azure_ai/Llama-4-Scout-17B-16E-Instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.78},"azure_ai/MAI-DS-R1":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"azure_ai/Meta-Llama-3-70B-Instruct":{"cr":0.11,"cw":1.1,"i":1.1,"o":0.37},"azure_ai/Meta-Llama-3.1-405B-Instruct":{"cr":0.533,"cw":5.33,"i":5.33,"o":16.0},"azure_ai/Meta-Llama-3.1-70B-Instruct":{"cr":0.268,"cw":2.68,"i":2.68,"o":3.54},"azure_ai/Meta-Llama-3.1-8B-Instruct":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.61},"azure_ai/Phi-3-medium-128k-instruct":{"cr":0.017,"cw":0.16999999999999998,"i":0.16999999999999998,"o":0.6799999999999999},"azure_ai/Phi-3-medium-4k-instruct":{"cr":0.017,"cw":0.16999999999999998,"i":0.16999999999999998,"o":0.6799999999999999},"azure_ai/Phi-3-mini-128k-instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.52},"azure_ai/Phi-3-mini-4k-instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.52},"azure_ai/Phi-3-small-128k-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"azure_ai/Phi-3-small-8k-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"azure_ai/Phi-3.5-MoE-instruct":{"cr":0.016,"cw":0.16,"i":0.16,"o":0.64},"azure_ai/Phi-3.5-mini-instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.52},"azure_ai/Phi-3.5-vision-instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.52},"azure_ai/Phi-4":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.5},"azure_ai/Phi-4-mini-instruct":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"azure_ai/Phi-4-mini-reasoning":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.32},"azure_ai/Phi-4-multimodal-instruct":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.32},"azure_ai/Phi-4-reasoning":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.5},"azure_ai/claude-fable-5":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"azure_ai/claude-haiku-4-5":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"azure_ai/claude-opus-4-1":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"azure_ai/claude-opus-4-5":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"azure_ai/claude-opus-4-6":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"azure_ai/claude-opus-4-7":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"azure_ai/claude-opus-4-8":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"azure_ai/claude-sonnet-4-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"azure_ai/claude-sonnet-4-6":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"azure_ai/claude-sonnet-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"azure_ai/cohere-rerank-v3-english":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"azure_ai/cohere-rerank-v3-multilingual":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"azure_ai/cohere-rerank-v3.5":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"azure_ai/cohere-rerank-v4.0-fast":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"azure_ai/cohere-rerank-v4.0-pro":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"azure_ai/deepseek-r1":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"azure_ai/deepseek-v3":{"cr":0.11400000000000002,"cw":1.1400000000000001,"i":1.1400000000000001,"o":4.5600000000000005},"azure_ai/deepseek-v3-0324":{"cr":0.11400000000000002,"cw":1.1400000000000001,"i":1.1400000000000001,"o":4.5600000000000005},"azure_ai/deepseek-v3.1":{"cr":0.123,"cw":1.23,"i":1.23,"o":4.94},"azure_ai/deepseek-v3.2":{"cr":0.057999999999999996,"cw":0.58,"i":0.58,"o":1.68},"azure_ai/deepseek-v3.2-speciale":{"cr":0.057999999999999996,"cw":0.58,"i":0.58,"o":1.68},"azure_ai/deepseek-v4-flash":{"cr":0.019000000000000003,"cw":0.19,"i":0.19,"o":0.51},"azure_ai/deepseek-v4-pro":{"cr":0.17400000000000002,"cw":1.74,"i":1.74,"o":3.48},"azure_ai/embed-v-4-0":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"azure_ai/global/grok-3":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"azure_ai/global/grok-3-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.27},"azure_ai/gpt-5.4":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"azure_ai/gpt-5.4-2026-03-05":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"azure_ai/gpt-5.4-mini":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"azure_ai/gpt-5.4-mini-2026-03-17":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"azure_ai/gpt-5.4-nano":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"azure_ai/gpt-5.4-nano-2026-03-17":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"azure_ai/gpt-5.4-pro":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure_ai/gpt-5.4-pro-2026-03-05":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"azure_ai/gpt-5.5":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"azure_ai/gpt-5.5-2026-04-23":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"azure_ai/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"azure_ai/grok-3":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"azure_ai/grok-3-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.27},"azure_ai/grok-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"azure_ai/grok-4-1-fast-non-reasoning":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"azure_ai/grok-4-1-fast-reasoning":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"azure_ai/grok-4-fast-non-reasoning":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"azure_ai/grok-4-fast-reasoning":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"azure_ai/grok-code-fast-1":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.5},"azure_ai/jais-30b-chat":{"cr":320.0,"cw":3200.0,"i":3200.0,"o":9710.0},"azure_ai/jamba-instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.7},"azure_ai/kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"azure_ai/kimi-k2.6":{"cr":0.095,"cw":0.95,"i":0.95,"o":4.0},"azure_ai/ministral-3b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.04},"azure_ai/mistral-large":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":12.0},"azure_ai/mistral-large-2407":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"azure_ai/mistral-large-3":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"azure_ai/mistral-large-latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"azure_ai/mistral-medium-2505":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"azure_ai/mistral-nemo":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"azure_ai/mistral-small":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"azure_ai/mistral-small-2503":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"azure_ai/model_router":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.0},"babbage-002":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.39999999999999997},"baseten/MiniMaxAI/MiniMax-M2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"baseten/deepseek-ai/DeepSeek-V3-0324":{"cr":0.07700000000000001,"cw":0.77,"i":0.77,"o":0.77},"baseten/deepseek-ai/DeepSeek-V3.1":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"baseten/moonshotai/Kimi-K2-Instruct-0905":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"baseten/moonshotai/Kimi-K2-Thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"baseten/moonshotai/Kimi-K2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"baseten/nvidia/Nemotron-120B-A12B":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.75},"baseten/openai/gpt-oss-120b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.5},"baseten/zai-org/GLM-4.6":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"baseten/zai-org/GLM-4.7":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"baseten/zai-org/GLM-5":{"cr":0.095,"cw":0.95,"i":0.95,"o":3.15},"bedrock/ap-northeast-1/anthropic.claude-instant-v1":{"cr":0.223,"cw":2.23,"i":2.23,"o":7.55},"bedrock/ap-northeast-1/anthropic.claude-v1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/ap-northeast-1/anthropic.claude-v2:1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/ap-northeast-1/deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"bedrock/ap-northeast-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-northeast-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-northeast-1/moonshotai.kimi-k2-thinking":{"cr":0.07300000000000001,"cw":0.73,"i":0.73,"o":3.03},"bedrock/ap-northeast-1/moonshotai.kimi-k2.5":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":3.5999999999999996},"bedrock/ap-northeast-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/ap-south-1/deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0":{"cr":0.318,"cw":3.18,"i":3.18,"o":4.199999999999999},"bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":0.72},"bedrock/ap-south-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-south-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-south-1/moonshotai.kimi-k2-thinking":{"cr":0.071,"cw":0.71,"i":0.71,"o":2.94},"bedrock/ap-south-1/moonshotai.kimi-k2.5":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":3.5999999999999996},"bedrock/ap-south-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/ap-southeast-2/minimax.minimax-m2.5":{"cr":0.0309,"cw":0.309,"i":0.309,"o":1.236},"bedrock/ap-southeast-3/deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"bedrock/ap-southeast-3/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-southeast-3/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/ap-southeast-3/moonshotai.kimi-k2.5":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":3.5999999999999996},"bedrock/ap-southeast-3/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0":{"cr":0.30500000000000005,"cw":3.05,"i":3.05,"o":4.03},"bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.69},"bedrock/eu-central-1/anthropic.claude-instant-v1":{"cr":0.248,"cw":2.48,"i":2.48,"o":8.379999999999999},"bedrock/eu-central-1/anthropic.claude-v1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/eu-central-1/anthropic.claude-v2:1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/eu-central-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-central-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-central-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/eu-north-1/deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"bedrock/eu-north-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-north-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-north-1/moonshotai.kimi-k2.5":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":3.5999999999999996},"bedrock/eu-south-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-south-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-south-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0":{"cr":0.28600000000000003,"cw":2.8600000000000003,"i":2.8600000000000003,"o":3.78},"bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0":{"cr":0.032,"cw":0.32,"i":0.32,"o":0.65},"bedrock/eu-west-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-west-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/eu-west-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0":{"cr":0.34500000000000003,"cw":3.45,"i":3.45,"o":4.55},"bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0":{"cr":0.03900000000000001,"cw":0.39,"i":0.39,"o":0.78},"bedrock/eu-west-2/minimax.minimax-m2.1":{"cr":0.04700000000000001,"cw":0.47,"i":0.47,"o":1.8599999999999999},"bedrock/eu-west-2/minimax.minimax-m2.5":{"cr":0.04700000000000001,"cw":0.47,"i":0.47,"o":1.8599999999999999},"bedrock/eu-west-2/qwen.qwen3-coder-next":{"cr":0.07800000000000001,"cw":0.78,"i":0.78,"o":1.8599999999999999},"bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.26},"bedrock/eu-west-3/mistral.mistral-large-2402-v1:0":{"cr":1.04,"cw":10.4,"i":10.4,"o":31.2},"bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1":{"cr":0.059,"cw":0.59,"i":0.59,"o":0.9099999999999999},"bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"bedrock/moonshotai.kimi-k2-thinking":{"cr":0.07300000000000001,"cw":0.73,"i":0.73,"o":3.03},"bedrock/moonshotai.kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.03},"bedrock/sa-east-1/deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0":{"cr":0.44499999999999995,"cw":4.45,"i":4.45,"o":5.88},"bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.01},"bedrock/sa-east-1/minimax.minimax-m2.1":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/sa-east-1/minimax.minimax-m2.5":{"cr":0.036000000000000004,"cw":0.36,"i":0.36,"o":1.44},"bedrock/sa-east-1/moonshotai.kimi-k2-thinking":{"cr":0.07300000000000001,"cw":0.73,"i":0.73,"o":3.03},"bedrock/sa-east-1/moonshotai.kimi-k2.5":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":3.5999999999999996},"bedrock/sa-east-1/qwen.qwen3-coder-next":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.44},"bedrock/us-east-1/anthropic.claude-instant-v1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"bedrock/us-east-1/anthropic.claude-v1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-east-1/anthropic.claude-v2:1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-east-1/deepseek.v3.2":{"cr":0.062,"cw":0.62,"i":0.62,"o":1.85},"bedrock/us-east-1/meta.llama3-70b-instruct-v1:0":{"cr":0.265,"cw":2.65,"i":2.65,"o":3.5},"bedrock/us-east-1/meta.llama3-8b-instruct-v1:0":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"bedrock/us-east-1/minimax.minimax-m2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-east-1/minimax.minimax-m2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.19999999999999998},"bedrock/us-east-1/mistral.mistral-large-2402-v1:0":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":0.7},"bedrock/us-east-1/moonshotai.kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"bedrock/us-east-1/moonshotai.kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"bedrock/us-east-1/qwen.qwen3-coder-next":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.2},"bedrock/us-east-1/zai.glm-5":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.1999999999999997},"bedrock/us-east-2/deepseek.v3.2":{"cr":0.062,"cw":0.62,"i":0.62,"o":1.85},"bedrock/us-east-2/minimax.minimax-m2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-east-2/minimax.minimax-m2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-east-2/moonshotai.kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"bedrock/us-east-2/moonshotai.kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"bedrock/us-east-2/qwen.qwen3-coder-next":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.2},"bedrock/us-gov-east-1/amazon.nova-pro-v1:0":{"cr":0.096,"cw":0.96,"i":0.96,"o":3.84},"bedrock/us-gov-east-1/amazon.titan-embed-text-v1":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"bedrock/us-gov-east-1/amazon.titan-text-express-v1":{"cr":0.13,"cw":1.3,"i":1.3,"o":1.7},"bedrock/us-gov-east-1/amazon.titan-text-lite-v1":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.39999999999999997},"bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.03,"cw":0.375,"i":0.3,"o":1.5},"bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.12,"cw":1.5,"i":1.2,"o":6.0},"bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0":{"cr":0.265,"cw":2.65,"i":2.65,"o":3.5},"bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.65},"bedrock/us-gov-west-1/amazon.nova-pro-v1:0":{"cr":0.096,"cw":0.96,"i":0.96,"o":3.84},"bedrock/us-gov-west-1/amazon.titan-embed-text-v1":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"bedrock/us-gov-west-1/amazon.titan-text-express-v1":{"cr":0.13,"cw":1.3,"i":1.3,"o":1.7},"bedrock/us-gov-west-1/amazon.titan-text-lite-v1":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.39999999999999997},"bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.03,"cw":0.375,"i":0.3,"o":1.5},"bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.12,"cw":1.5,"i":1.2,"o":6.0},"bedrock/us-gov-west-1/anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-west-1/claude-sonnet-4-5-20250929-v1:0":{"cr":0.36,"cw":4.5,"i":3.5999999999999996,"o":18.0},"bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0":{"cr":0.265,"cw":2.65,"i":2.65,"o":3.5},"bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.65},"bedrock/us-west-1/meta.llama3-70b-instruct-v1:0":{"cr":0.265,"cw":2.65,"i":2.65,"o":3.5},"bedrock/us-west-1/meta.llama3-8b-instruct-v1:0":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"bedrock/us-west-2/anthropic.claude-instant-v1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"bedrock/us-west-2/anthropic.claude-v1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-west-2/anthropic.claude-v2:1":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-west-2/deepseek.v3.2":{"cr":0.062,"cw":0.62,"i":0.62,"o":1.85},"bedrock/us-west-2/minimax.minimax-m2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-west-2/minimax.minimax-m2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.19999999999999998},"bedrock/us-west-2/mistral.mistral-large-2402-v1:0":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":0.7},"bedrock/us-west-2/moonshotai.kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"bedrock/us-west-2/moonshotai.kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"bedrock/us-west-2/qwen.qwen3-coder-next":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.2},"bedrock/us-west-2/zai.glm-5":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.1999999999999997},"bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0":{"cr":0.08,"cw":1.0,"i":0.7999999999999999,"o":4.0},"bedrock_mantle/google.gemma-4-26b-a4b":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"bedrock_mantle/google.gemma-4-31b":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.39999999999999997},"bedrock_mantle/google.gemma-4-e2b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.08},"bedrock_mantle/openai.gpt-5.4":{"cr":0.275,"cw":2.75,"i":2.75,"o":16.5},"bedrock_mantle/openai.gpt-5.5":{"cr":0.55,"cw":5.5,"i":5.5,"o":33.0},"bedrock_mantle/openai.gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"bedrock_mantle/openai.gpt-oss-20b":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"bedrock_mantle/openai.gpt-oss-safeguard-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"bedrock_mantle/openai.gpt-oss-safeguard-20b":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"cerebras/gpt-oss-120b":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.75},"cerebras/llama-3.3-70b":{"cr":0.085,"cw":0.85,"i":0.85,"o":1.2},"cerebras/llama3.1-70b":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"cerebras/llama3.1-8b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"cerebras/qwen-3-32b":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.7999999999999999},"cerebras/zai-glm-4.6":{"cr":0.225,"cw":2.25,"i":2.25,"o":2.75},"cerebras/zai-glm-4.7":{"cr":0.225,"cw":2.25,"i":2.25,"o":2.75},"chatdolphin":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"chatgpt-4o-latest":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"claude-3-7-sonnet-20250219":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"claude-3-haiku-20240307":{"cr":0.03,"cw":0.3,"i":0.25,"o":1.25},"claude-3-opus-20240229":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"claude-4-opus-20250514":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"claude-4-sonnet-20250514":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"claude-fable-5":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"claude-haiku-4-5":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"claude-haiku-4-5-20251001":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"claude-opus-4-1":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"claude-opus-4-1-20250805":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"claude-opus-4-20250514":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"claude-opus-4-5":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"claude-opus-4-5-20251101":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"claude-opus-4-6":{"cr":0.5,"cw":6.25,"fast":6.0,"i":5.0,"o":25.0},"claude-opus-4-6-20260205":{"cr":0.5,"cw":6.25,"fast":6.0,"i":5.0,"o":25.0},"claude-opus-4-7":{"cr":0.5,"cw":6.25,"fast":6.0,"i":5.0,"o":25.0},"claude-opus-4-7-20260416":{"cr":0.5,"cw":6.25,"fast":6.0,"i":5.0,"o":25.0},"claude-opus-4-8":{"cr":0.5,"cw":6.25,"fast":2.0,"i":5.0,"o":25.0},"claude-sonnet-4-20250514":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"claude-sonnet-4-5":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"claude-sonnet-4-5-20250929":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"claude-sonnet-4-5-20250929-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"claude-sonnet-4-6":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"claude-sonnet-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"cloudflare/@cf/aisingapore/gemma-sea-lion-v4-27b-it":{"cr":0.035100000000000006,"cw":0.351,"i":0.351,"o":0.5549999999999999},"cloudflare/@cf/deepseek-ai/deepseek-r1-distill-qwen-32b":{"cr":0.0497,"cw":0.49699999999999994,"i":0.49699999999999994,"o":4.881},"cloudflare/@cf/google/gemma-2b-it-lora":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"cloudflare/@cf/google/gemma-4-26b-a4b-it":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"cloudflare/@cf/google/gemma-7b-it-lora":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"cloudflare/@cf/ibm-granite/granite-4.0-h-micro":{"cr":0.0017000000000000001,"cw":0.017,"i":0.017,"o":0.112},"cloudflare/@cf/meta-llama/llama-2-7b-chat-hf-lora":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"cloudflare/@cf/meta/llama-2-7b-chat-fp16":{"cr":0.19230000000000003,"cw":1.923,"i":1.923,"o":1.923},"cloudflare/@cf/meta/llama-2-7b-chat-int8":{"cr":0.19230000000000003,"cw":1.923,"i":1.923,"o":1.923},"cloudflare/@cf/meta/llama-3.1-8b-instruct-fp8":{"cr":0.0152,"cw":0.15200000000000002,"i":0.15200000000000002,"o":0.28700000000000003},"cloudflare/@cf/meta/llama-3.2-11b-vision-instruct":{"cr":0.00485,"cw":0.048499999999999995,"i":0.048499999999999995,"o":0.6759999999999999},"cloudflare/@cf/meta/llama-3.2-1b-instruct":{"cr":0.0027,"cw":0.027,"i":0.027,"o":0.201},"cloudflare/@cf/meta/llama-3.2-3b-instruct":{"cr":0.005090000000000001,"cw":0.0509,"i":0.0509,"o":0.335},"cloudflare/@cf/meta/llama-3.3-70b-instruct-fp8-fast":{"cr":0.0293,"cw":0.293,"i":0.293,"o":2.2529999999999997},"cloudflare/@cf/meta/llama-4-scout-17b-16e-instruct":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":0.85},"cloudflare/@cf/meta/llama-guard-3-8b":{"cr":0.04840000000000001,"cw":0.48400000000000004,"i":0.48400000000000004,"o":0.03},"cloudflare/@cf/mistral/mistral-7b-instruct-v0.1":{"cr":0.19230000000000003,"cw":1.923,"i":1.923,"o":1.923},"cloudflare/@cf/mistral/mistral-7b-instruct-v0.2-lora":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"cloudflare/@cf/mistralai/mistral-small-3.1-24b-instruct":{"cr":0.035100000000000006,"cw":0.351,"i":0.351,"o":0.5549999999999999},"cloudflare/@cf/moonshotai/kimi-k2.6":{"cr":0.16,"cw":0.95,"i":0.95,"o":4.0},"cloudflare/@cf/moonshotai/kimi-k2.7-code":{"cr":0.19,"cw":0.95,"i":0.95,"o":4.0},"cloudflare/@cf/nvidia/nemotron-3-120b-a12b":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"cloudflare/@cf/openai/gpt-oss-120b":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.75},"cloudflare/@cf/openai/gpt-oss-20b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.3},"cloudflare/@cf/qwen/qwen2.5-coder-32b-instruct":{"cr":0.066,"cw":0.66,"i":0.66,"o":1.0},"cloudflare/@cf/qwen/qwen3-30b-a3b-fp8":{"cr":0.005090000000000001,"cw":0.0509,"i":0.0509,"o":0.335},"cloudflare/@cf/qwen/qwq-32b":{"cr":0.066,"cw":0.66,"i":0.66,"o":1.0},"cloudflare/@cf/zai-org/glm-4.7-flash":{"cr":0.0060500000000000016,"cw":0.060500000000000005,"i":0.060500000000000005,"o":0.39999999999999997},"cloudflare/@cf/zai-org/glm-5.2":{"cr":0.26,"cw":1.4,"i":1.4,"o":4.4},"cloudflare/@hf/thebloke/codellama-7b-instruct-awq":{"cr":0.19230000000000003,"cw":1.923,"i":1.923,"o":1.923},"codestral/codestral-2405":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"codestral/codestral-latest":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"codex-mini-latest":{"cr":0.375,"cw":1.5,"i":1.5,"o":6.0},"cohere.command-light-text-v14":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"cohere.command-r-plus-v1:0":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"cohere.command-r-v1:0":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"cohere.command-text-v14":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"cohere.embed-english-v3":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"cohere.embed-multilingual-v3":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"cohere.embed-v4:0":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"cohere.rerank-v3-5:0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"cohere/embed-v4.0":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"command":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":2.0},"command-a-03-2025":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"command-light":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"command-nightly":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":2.0},"command-r":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"command-r-08-2024":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"command-r-plus":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"command-r-plus-08-2024":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"command-r7b-12-2024":{"cr":0.00375,"cw":0.0375,"i":0.0375,"o":0.15},"computer-use-preview":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":12.0},"crusoe/Qwen/Qwen3-235B-A22B-Instruct-2507":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":3.0},"crusoe/deepseek-ai/DeepSeek-R1-0528":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":7.0},"crusoe/deepseek-ai/DeepSeek-V3-0324":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":1.5},"crusoe/google/gemma-3-12b-it":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"crusoe/meta-llama/Llama-3.3-70B-Instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"crusoe/moonshotai/Kimi-K2-Thinking":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":2.5},"crusoe/openai/gpt-oss-120b":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"darkbloom/gemma-4-26b":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.165},"darkbloom/gpt-oss-20b":{"cr":0.0014500000000000001,"cw":0.0145,"i":0.0145,"o":0.07},"dashscope/qwen-coder":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.5},"dashscope/qwen-max":{"cr":0.16,"cw":1.5999999999999999,"i":1.5999999999999999,"o":6.3999999999999995},"dashscope/qwen-plus":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.2},"dashscope/qwen-plus-2025-01-25":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.2},"dashscope/qwen-plus-2025-04-28":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.2},"dashscope/qwen-plus-2025-07-14":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.2},"dashscope/qwen-turbo":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.19999999999999998},"dashscope/qwen-turbo-2024-11-01":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.19999999999999998},"dashscope/qwen-turbo-2025-04-28":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.19999999999999998},"dashscope/qwen-turbo-latest":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.19999999999999998},"dashscope/qwen3-next-80b-a3b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.2},"dashscope/qwen3-next-80b-a3b-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.2},"dashscope/qwen3-vl-235b-a22b-instruct":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"dashscope/qwen3-vl-235b-a22b-thinking":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":4.0},"dashscope/qwen3-vl-32b-instruct":{"cr":0.016,"cw":0.16,"i":0.16,"o":0.64},"dashscope/qwen3-vl-32b-thinking":{"cr":0.016,"cw":0.16,"i":0.16,"o":2.87},"dashscope/qwq-plus":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"databricks/databricks-bge-large-en":{"cr":0.010003,"cw":0.10003000000000001,"i":0.10003000000000001,"o":0.0},"databricks/databricks-claude-3-7-sonnet":{"cr":0.29999900000000007,"cw":2.9999900000000004,"i":2.9999900000000004,"o":15.000020000000001},"databricks/databricks-claude-haiku-4-5":{"cr":0.10000200000000001,"cw":1.0000200000000001,"i":1.0000200000000001,"o":5.00003},"databricks/databricks-claude-opus-4":{"cr":1.5000020000000003,"cw":15.000020000000001,"i":15.000020000000001,"o":75.00003000000001},"databricks/databricks-claude-opus-4-1":{"cr":1.5000020000000003,"cw":15.000020000000001,"i":15.000020000000001,"o":75.00003000000001},"databricks/databricks-claude-opus-4-5":{"cr":0.5000030000000001,"cw":5.00003,"i":5.00003,"o":25.000010000000003},"databricks/databricks-claude-sonnet-4":{"cr":0.29999900000000007,"cw":2.9999900000000004,"i":2.9999900000000004,"o":15.000020000000001},"databricks/databricks-claude-sonnet-4-1":{"cr":0.29999900000000007,"cw":2.9999900000000004,"i":2.9999900000000004,"o":15.000020000000001},"databricks/databricks-claude-sonnet-4-5":{"cr":0.29999900000000007,"cw":2.9999900000000004,"i":2.9999900000000004,"o":15.000020000000001},"databricks/databricks-gemini-2-5-flash":{"cr":0.030002,"cw":0.30001999999999995,"i":0.30001999999999995,"o":2.49998},"databricks/databricks-gemini-2-5-pro":{"cr":0.12499900000000001,"cw":1.24999,"i":1.24999,"o":9.999990000000002},"databricks/databricks-gemma-3-12b":{"cr":0.015001,"cw":0.15000999999999998,"i":0.15000999999999998,"o":0.5000100000000001},"databricks/databricks-gpt-5":{"cr":0.12499900000000001,"cw":1.24999,"i":1.24999,"o":9.999990000000002},"databricks/databricks-gpt-5-1":{"cr":0.12499900000000001,"cw":1.24999,"i":1.24999,"o":9.999990000000002},"databricks/databricks-gpt-5-mini":{"cr":0.02499700000000001,"cw":0.24997000000000005,"i":0.24997000000000005,"o":1.9999700000000002},"databricks/databricks-gpt-5-nano":{"cr":0.004998000000000001,"cw":0.049980000000000004,"i":0.049980000000000004,"o":0.39998000000000006},"databricks/databricks-gpt-oss-120b":{"cr":0.015001,"cw":0.15000999999999998,"i":0.15000999999999998,"o":0.59997},"databricks/databricks-gpt-oss-20b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.30001999999999995},"databricks/databricks-gte-large-en":{"cr":0.012999000000000004,"cw":0.12999000000000002,"i":0.12999000000000002,"o":0.0},"databricks/databricks-llama-2-70b-chat":{"cr":0.050001000000000004,"cw":0.5000100000000001,"i":0.5000100000000001,"o":1.5000300000000002},"databricks/databricks-llama-4-maverick":{"cr":0.050001000000000004,"cw":0.5000100000000001,"i":0.5000100000000001,"o":1.5000300000000002},"databricks/databricks-meta-llama-3-1-405b-instruct":{"cr":0.5000030000000001,"cw":5.00003,"i":5.00003,"o":15.000020000000001},"databricks/databricks-meta-llama-3-1-8b-instruct":{"cr":0.015001,"cw":0.15000999999999998,"i":0.15000999999999998,"o":0.45003000000000004},"databricks/databricks-meta-llama-3-3-70b-instruct":{"cr":0.050001000000000004,"cw":0.5000100000000001,"i":0.5000100000000001,"o":1.5000300000000002},"databricks/databricks-meta-llama-3-70b-instruct":{"cr":0.10000200000000001,"cw":1.0000200000000001,"i":1.0000200000000001,"o":2.9999900000000004},"databricks/databricks-mixtral-8x7b-instruct":{"cr":0.050001000000000004,"cw":0.5000100000000001,"i":0.5000100000000001,"o":1.0000200000000001},"databricks/databricks-mpt-30b-instruct":{"cr":0.10000200000000001,"cw":1.0000200000000001,"i":1.0000200000000001,"o":1.0000200000000001},"databricks/databricks-mpt-7b-instruct":{"cr":0.050001000000000004,"cw":0.5000100000000001,"i":0.5000100000000001,"o":0.0},"davinci-002":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"deep-research-pro-preview-12-2025":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"deepinfra/Gryphe/MythoMax-L2-13b":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.09},"deepinfra/NousResearch/Hermes-3-Llama-3.1-405B":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"deepinfra/NousResearch/Hermes-3-Llama-3.1-70B":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"deepinfra/Qwen/QwQ-32B":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.39999999999999997},"deepinfra/Qwen/Qwen2.5-72B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.39},"deepinfra/Qwen/Qwen2.5-7B-Instruct":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.09999999999999999},"deepinfra/Qwen/Qwen2.5-VL-32B-Instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"deepinfra/Qwen/Qwen3-14B":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"deepinfra/Qwen/Qwen3-235B-A22B":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.54},"deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.6},"deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.9000000000000004},"deepinfra/Qwen/Qwen3-30B-A3B":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.29},"deepinfra/Qwen/Qwen3-32B":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.28},"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo":{"cr":0.028999999999999998,"cw":0.29,"i":0.29,"o":1.2},"deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":1.4},"deepinfra/Qwen/Qwen3-Next-80B-A3B-Thinking":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":1.4},"deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.049999999999999996},"deepinfra/Sao10K/L3.1-70B-Euryale-v2.2":{"cr":0.065,"cw":0.65,"i":0.65,"o":0.75},"deepinfra/Sao10K/L3.3-70B-Euryale-v2.3":{"cr":0.065,"cw":0.65,"i":0.65,"o":0.75},"deepinfra/allenai/olmOCR-7B-0725-FP8":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":1.5},"deepinfra/anthropic/claude-3-7-sonnet-latest":{"cr":0.33,"cw":3.3000000000000003,"i":3.3000000000000003,"o":16.5},"deepinfra/anthropic/claude-4-opus":{"cr":1.6500000000000004,"cw":16.5,"i":16.5,"o":82.5},"deepinfra/anthropic/claude-4-sonnet":{"cr":0.33,"cw":3.3000000000000003,"i":3.3000000000000003,"o":16.5},"deepinfra/deepseek-ai/DeepSeek-R1":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.4},"deepinfra/deepseek-ai/DeepSeek-R1-0528":{"cr":0.39999999999999997,"cw":0.5,"i":0.5,"o":2.1500000000000004},"deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":0.27},"deepinfra/deepseek-ai/DeepSeek-R1-Turbo":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"deepinfra/deepseek-ai/DeepSeek-V3":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":0.8899999999999999},"deepinfra/deepseek-ai/DeepSeek-V3-0324":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.88},"deepinfra/deepseek-ai/DeepSeek-V3.1":{"cr":0.216,"cw":0.27,"i":0.27,"o":1.0},"deepinfra/deepseek-ai/DeepSeek-V3.1-Terminus":{"cr":0.216,"cw":0.27,"i":0.27,"o":1.0},"deepinfra/google/gemini-2.0-flash-001":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"deepinfra/google/gemini-2.5-flash":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"deepinfra/google/gemini-2.5-pro":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"deepinfra/google/gemma-3-12b-it":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"deepinfra/google/gemma-3-27b-it":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.16},"deepinfra/google/gemma-3-4b-it":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.08},"deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct":{"cr":0.004900000000000001,"cw":0.049,"i":0.049,"o":0.049},"deepinfra/meta-llama/Llama-3.2-3B-Instruct":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.02},"deepinfra/meta-llama/Llama-3.3-70B-Instruct":{"cr":0.023,"cw":0.22999999999999998,"i":0.22999999999999998,"o":0.39999999999999997},"deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39},"deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.3},"deepinfra/meta-llama/Llama-Guard-3-8B":{"cr":0.0055000000000000005,"cw":0.055,"i":0.055,"o":0.055},"deepinfra/meta-llama/Llama-Guard-4-12B":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.18},"deepinfra/meta-llama/Meta-Llama-3-8B-Instruct":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.06},"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.39999999999999997},"deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.28},"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.049999999999999996},"deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.03},"deepinfra/microsoft/WizardLM-2-8x22B":{"cr":0.048,"cw":0.48,"i":0.48,"o":0.48},"deepinfra/microsoft/phi-4":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.14},"deepinfra/mistralai/Mistral-Nemo-Instruct-2407":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.04},"deepinfra/mistralai/Mistral-Small-24B-Instruct-2501":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.08},"deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.19999999999999998},"deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.39999999999999997},"deepinfra/moonshotai/Kimi-K2-Instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":2.0},"deepinfra/moonshotai/Kimi-K2-Instruct-0905":{"cr":0.39999999999999997,"cw":0.5,"i":0.5,"o":2.0},"deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.16},"deepinfra/openai/gpt-oss-120b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.44999999999999996},"deepinfra/openai/gpt-oss-20b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.15},"deepinfra/zai-org/GLM-4.5":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"deepseek-chat":{"cr":0.028,"cw":0.28,"i":0.28,"o":0.42},"deepseek-reasoner":{"cr":0.028,"cw":0.28,"i":0.28,"o":0.42},"deepseek-v3-2-251201":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"deepseek-v4-flash":{"cr":0.0028,"cw":0.0,"i":0.14,"o":0.28},"deepseek-v4-pro":{"cr":0.003625,"cw":0.0,"i":0.435,"o":0.87},"deepseek.v3-v1:0":{"cr":0.057999999999999996,"cw":0.58,"i":0.58,"o":1.68},"deepseek.v3.2":{"cr":0.062,"cw":0.62,"i":0.62,"o":1.85},"deepseek/deepseek-chat":{"cr":0.028,"cw":0.0,"i":0.28,"o":0.42},"deepseek/deepseek-coder":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.28},"deepseek/deepseek-r1":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"deepseek/deepseek-reasoner":{"cr":0.028,"cw":0.28,"i":0.28,"o":0.42},"deepseek/deepseek-v3":{"cr":0.07,"cw":0.0,"i":0.27,"o":1.1},"deepseek/deepseek-v3.2":{"cr":0.028000000000000004,"cw":0.28,"i":0.28,"o":0.39999999999999997},"deepseek/deepseek-v4-flash":{"cr":0.0028,"cw":0.0,"i":0.14,"o":0.28},"deepseek/deepseek-v4-pro":{"cr":0.003625,"cw":0.0,"i":0.435,"o":0.87},"dolphin":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"doubao-embedding":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"doubao-embedding-large":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"doubao-embedding-large-text-240915":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"doubao-embedding-large-text-250515":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"doubao-embedding-text-240715":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"embed-english-light-v2.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"embed-english-light-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"embed-english-v2.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"embed-english-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"embed-multilingual-light-v3.0":{"cr":10.0,"cw":100.0,"i":100.0,"o":0.0},"embed-multilingual-v2.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"embed-multilingual-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"eu.amazon.nova-2-lite-v1:0":{"cr":0.0825,"cw":0.33,"i":0.33,"o":2.75},"eu.amazon.nova-2-pro-preview-20251202-v1:0":{"cr":0.546875,"cw":2.1875,"i":2.1875,"o":17.5},"eu.amazon.nova-lite-v1:0":{"cr":0.0078000000000000005,"cw":0.078,"i":0.078,"o":0.312},"eu.amazon.nova-micro-v1:0":{"cr":0.004600000000000001,"cw":0.046,"i":0.046,"o":0.184},"eu.amazon.nova-pro-v1:0":{"cr":0.105,"cw":1.0499999999999998,"i":1.0499999999999998,"o":4.199999999999999},"eu.anthropic.claude-3-5-haiku-20241022-v1:0":{"cr":0.024999999999999998,"cw":0.3125,"i":0.25,"o":1.25},"eu.anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"eu.anthropic.claude-3-5-sonnet-20241022-v2:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"eu.anthropic.claude-3-7-sonnet-20250219-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"eu.anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.024999999999999998,"cw":0.3125,"i":0.25,"o":1.25},"eu.anthropic.claude-3-opus-20240229-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"eu.anthropic.claude-3-sonnet-20240229-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"eu.anthropic.claude-fable-5":{"cr":1.1,"cw":13.75,"i":11.0,"o":55.0},"eu.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.11,"cw":1.375,"i":1.1,"o":5.5},"eu.anthropic.claude-opus-4-1-20250805-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"eu.anthropic.claude-opus-4-20250514-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"eu.anthropic.claude-opus-4-5-20251101-v1:0":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"eu.anthropic.claude-opus-4-6-v1":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"eu.anthropic.claude-opus-4-7":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"eu.anthropic.claude-opus-4-8":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"eu.anthropic.claude-sonnet-4-20250514-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"eu.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.33,"cra":0.66,"cw":4.125,"cwa":8.25,"i":3.3000000000000003,"ia":6.6000000000000005,"o":16.5,"oa":24.75},"eu.anthropic.claude-sonnet-4-6":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"eu.anthropic.claude-sonnet-5":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"eu.deepseek.v3.2":{"cr":0.074,"cw":0.74,"i":0.74,"o":2.2199999999999998},"eu.meta.llama3-2-1b-instruct-v1:0":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.13},"eu.meta.llama3-2-3b-instruct-v1:0":{"cr":0.019000000000000003,"cw":0.19,"i":0.19,"o":0.19},"eu.mistral.pixtral-large-2502-v1:0":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"eu.twelvelabs.marengo-embed-2-7-v1:0":{"cr":7.0,"cw":70.0,"i":70.0,"o":0.0},"fireworks-ai-4.1b-to-16b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks-ai-56b-to-176b":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks-ai-above-16b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks-ai-default":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks-ai-embedding-150m-to-350m":{"cr":0.0016000000000000003,"cw":0.016,"i":0.016,"o":0.0},"fireworks-ai-embedding-up-to-150m":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"fireworks-ai-moe-up-to-56b":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks-ai-up-to-4b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/WhereIsAI/UAE-Large-V1":{"cr":0.0016000000000000003,"cw":0.016,"i":0.016,"o":0.0},"fireworks_ai/accounts/fireworks/models/":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"fireworks_ai/accounts/fireworks/models/SSD-1B":{"cr":1.3000000000000001e-05,"cw":0.00013,"i":0.00013,"o":0.00013},"fireworks_ai/accounts/fireworks/models/chronos-hermes-13b-v2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-13b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-13b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-13b-python":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-34b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-34b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-34b-python":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-70b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-70b-python":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/code-llama-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-llama-7b-python":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/code-qwen-1p5-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/codegemma-2b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/codegemma-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/cogito-671b-v2-p1":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-70b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-llama-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-14b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/cogito-v1-preview-qwen-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/dbrx-instruct":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/deepseek-coder-1b-base":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-coder-33b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-base-v1p5":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-coder-7b-instruct-v1p5":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-base":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-lite-instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/deepseek-prover-v2":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/deepseek-r1":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":8.0},"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":8.0},"fireworks_ai/accounts/fireworks/models/deepseek-r1-0528-distill-qwen3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-r1-basic":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-70b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-llama-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-14b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-1p5b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-r1-distill-qwen-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/deepseek-v2-lite-chat":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/deepseek-v2p5":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/deepseek-v3":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-v3-0324":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/deepseek-v3p1":{"cr":0.05600000000000001,"cw":0.56,"i":0.56,"o":1.68},"fireworks_ai/accounts/fireworks/models/deepseek-v3p1-terminus":{"cr":0.05600000000000001,"cw":0.56,"i":0.56,"o":1.68},"fireworks_ai/accounts/fireworks/models/deepseek-v3p2":{"cr":0.05600000000000001,"cw":0.56,"i":0.56,"o":1.68},"fireworks_ai/accounts/fireworks/models/deepseek-v4-flash":{"cr":0.028,"cw":0.14,"i":0.14,"o":0.28},"fireworks_ai/accounts/fireworks/models/deepseek-v4-pro":{"cr":0.145,"cw":1.74,"i":1.74,"o":3.48},"fireworks_ai/accounts/fireworks/models/devstral-small-2505":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/dobby-mini-unhinged-plus-llama-3-1-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/dobby-unhinged-llama-3-3-70b-new":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/dolphin-2-9-2-qwen2-72b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/dolphin-2p6-mixtral-8x7b":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/ernie-4p5-21b-a3b-pt":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/ernie-4p5-300b-a47b-pt":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/fare-20b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/firefunction-v1":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/firefunction-v2":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/firellava-13b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/firesearch-ocr-v6":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/fireworks-asr-large":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/fireworks-asr-v2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/flux-1-dev":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/flux-1-dev-controlnet-union":{"cr":0.00010000000000000002,"cw":0.001,"i":0.001,"o":0.001},"fireworks_ai/accounts/fireworks/models/flux-1-dev-fp8":{"cr":5.000000000000001e-05,"cw":0.0005,"i":0.0005,"o":0.0005},"fireworks_ai/accounts/fireworks/models/flux-1-schnell":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/flux-1-schnell-fp8":{"cr":3.5000000000000004e-05,"cw":0.00035,"i":0.00035,"o":0.00035},"fireworks_ai/accounts/fireworks/models/flux-kontext-max":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.08},"fireworks_ai/accounts/fireworks/models/flux-kontext-pro":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.04},"fireworks_ai/accounts/fireworks/models/gemma-2b-it":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/gemma-3-27b-it":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/gemma-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/gemma-7b-it":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/gemma2-9b-it":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/glm-4p5":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"fireworks_ai/accounts/fireworks/models/glm-4p5-air":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/glm-4p5v":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/glm-4p6":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"fireworks_ai/accounts/fireworks/models/glm-4p7":{"cr":0.3,"cw":0.6,"i":0.6,"o":2.2},"fireworks_ai/accounts/fireworks/models/glm-5p1":{"cr":0.26,"cw":1.4,"i":1.4,"o":4.4},"fireworks_ai/accounts/fireworks/models/glm-5p2":{"cr":0.26,"cw":1.4,"i":1.4,"o":4.4},"fireworks_ai/accounts/fireworks/models/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/gpt-oss-20b":{"cr":0.035,"cw":0.07,"i":0.07,"o":0.3},"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-120b":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/gpt-oss-safeguard-20b":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/hermes-2-pro-mistral-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/internvl3-38b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/internvl3-78b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/internvl3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/japanese-stable-diffusion-xl":{"cr":1.3000000000000001e-05,"cw":0.00013,"i":0.00013,"o":0.00013},"fireworks_ai/accounts/fireworks/models/kat-coder":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/kat-dev-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/kat-dev-72b-exp":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"fireworks_ai/accounts/fireworks/models/kimi-k2-instruct-0905":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"fireworks_ai/accounts/fireworks/models/kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"fireworks_ai/accounts/fireworks/models/kimi-k2p5":{"cr":0.09999999999999999,"cw":0.6,"i":0.6,"o":3.0},"fireworks_ai/accounts/fireworks/models/kimi-k2p6":{"cr":0.16,"cw":0.95,"i":0.95,"o":4.0},"fireworks_ai/accounts/fireworks/models/kimi-k2p7-code":{"cr":0.19,"cw":0.95,"i":0.95,"o":4.0},"fireworks_ai/accounts/fireworks/models/llama-guard-2-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-guard-3-1b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-guard-3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v2-13b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v2-13b-chat":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v2-70b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v2-70b-chat":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v2-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v2-7b-chat":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3-70b-instruct-hf":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v3-8b-instruct-hf":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":3.0},"fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct-long":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct-1b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p1-nemotron-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/llamaguard-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/llava-yi-34b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/minimax-m1-80k":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/minimax-m2":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/accounts/fireworks/models/minimax-m2p1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/accounts/fireworks/models/minimax-m2p7":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/accounts/fireworks/models/minimax-m3":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/accounts/fireworks/models/ministral-3-14b-instruct-2512":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/ministral-3-3b-instruct-2512":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/ministral-3-8b-instruct-2512":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-4k":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v0p2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-7b-instruct-v3":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-7b-v0p2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-large-3-fp8":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/mistral-nemo-base-2407":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-nemo-instruct-2407":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/mistral-small-24b-instruct-2501":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/mixtral-8x22b":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"fireworks_ai/accounts/fireworks/models/mixtral-8x7b":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/mixtral-8x7b-instruct-hf":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/mythomax-l2-13b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/nemotron-nano-v2-12b-vl":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/nous-capybara-7b-v1p9":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/nous-hermes-2-mixtral-8x7b-dpo":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/nous-hermes-2-yi-34b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-13b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-70b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/nous-hermes-llama2-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-12b-v2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/nvidia-nemotron-nano-9b-v2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/openchat-3p5-0106-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/openhermes-2-mistral-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/openhermes-2p5-mistral-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/openorca-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/phi-2-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/phi-3-mini-128k-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/phi-3-vision-128k-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-python-v1":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v1":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/phind-code-llama-34b-v2":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/playground-v2-1024px-aesthetic":{"cr":1.3000000000000001e-05,"cw":0.00013,"i":0.00013,"o":0.00013},"fireworks_ai/accounts/fireworks/models/playground-v2-5-1024px-aesthetic":{"cr":1.3000000000000001e-05,"cw":0.00013,"i":0.00013,"o":0.00013},"fireworks_ai/accounts/fireworks/models/pythia-12b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen-qwq-32b-preview":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen-v2p5-14b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen-v2p5-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen1p5-72b-chat":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2-vl-2b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2-vl-72b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2-vl-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-0p5b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-14b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-1p5b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-32b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-72b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-72b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-0p5b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-14b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-1p5b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-128k":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-32k-rope":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct-64k":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-3b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-coder-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-math-72b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-32b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-3b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-72b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen2p5-vl-7b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-0p6b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-14b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-1p7b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-131072":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-1p7b-fp8-draft-40960":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-instruct-2507":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/qwen3-235b-a22b-thinking-2507":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-instruct-2507":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":0.5},"fireworks_ai/accounts/fireworks/models/qwen3-30b-a3b-thinking-2507":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-4b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-4b-instruct-2507":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-coder-30b-a3b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":1.7999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3-coder-480b-instruct-bf16":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-embedding-0p6b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/qwen3-embedding-4b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-next-80b-a3b-thinking":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-reranker-0p6b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/qwen3-reranker-4b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/qwen3-reranker-8b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-instruct":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/qwen3-vl-235b-a22b-thinking":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/qwen3-vl-30b-a3b-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/accounts/fireworks/models/qwen3-vl-32b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/qwen3-vl-8b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/qwen3p7-plus":{"cr":0.08,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"fireworks_ai/accounts/fireworks/models/qwq-32b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/rolm-ocr":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/snorkel-mistral-7b-pairrm-dpo":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/stable-diffusion-xl-1024-v1-0":{"cr":1.3000000000000001e-05,"cw":0.00013,"i":0.00013,"o":0.00013},"fireworks_ai/accounts/fireworks/models/stablecode-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/starcoder-16b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/starcoder-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/starcoder2-15b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/starcoder2-3b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"fireworks_ai/accounts/fireworks/models/starcoder2-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/toppy-m-7b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/yi-34b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/yi-34b-200k-capybara":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/yi-34b-chat":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"fireworks_ai/accounts/fireworks/models/yi-6b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/models/yi-large":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":3.0},"fireworks_ai/accounts/fireworks/models/zephyr-7b-beta":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"fireworks_ai/accounts/fireworks/routers/glm-5p1-fast":{"cr":0.52,"cw":2.8,"i":2.8,"o":8.8},"fireworks_ai/accounts/fireworks/routers/kimi-k2p6-fast":{"cr":0.3,"cw":2.0,"i":2.0,"o":8.0},"fireworks_ai/accounts/fireworks/routers/kimi-k2p7-code-fast":{"cr":0.38,"cw":1.9,"i":1.9,"o":8.0},"fireworks_ai/deepseek-v4-flash":{"cr":0.028,"cw":0.14,"i":0.14,"o":0.28},"fireworks_ai/deepseek-v4-pro":{"cr":0.145,"cw":1.74,"i":1.74,"o":3.48},"fireworks_ai/glm-4p7":{"cr":0.3,"cw":0.6,"i":0.6,"o":2.2},"fireworks_ai/glm-5p1":{"cr":0.26,"cw":1.4,"i":1.4,"o":4.4},"fireworks_ai/glm-5p1-fast":{"cr":0.52,"cw":2.8,"i":2.8,"o":8.8},"fireworks_ai/glm-5p2":{"cr":0.26,"cw":1.4,"i":1.4,"o":4.4},"fireworks_ai/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"fireworks_ai/gpt-oss-20b":{"cr":0.035,"cw":0.07,"i":0.07,"o":0.3},"fireworks_ai/kimi-k2p5":{"cr":0.09999999999999999,"cw":0.6,"i":0.6,"o":3.0},"fireworks_ai/kimi-k2p6":{"cr":0.16,"cw":0.95,"i":0.95,"o":4.0},"fireworks_ai/kimi-k2p6-fast":{"cr":0.3,"cw":2.0,"i":2.0,"o":8.0},"fireworks_ai/kimi-k2p7-code":{"cr":0.19,"cw":0.95,"i":0.95,"o":4.0},"fireworks_ai/kimi-k2p7-code-fast":{"cr":0.38,"cw":1.9,"i":1.9,"o":8.0},"fireworks_ai/minimax-m2p1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/minimax-m2p7":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/minimax-m3":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"fireworks_ai/nomic-ai/nomic-embed-text-v1":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"fireworks_ai/nomic-ai/nomic-embed-text-v1.5":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"fireworks_ai/qwen3p7-plus":{"cr":0.08,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"fireworks_ai/thenlper/gte-base":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"fireworks_ai/thenlper/gte-large":{"cr":0.0016000000000000003,"cw":0.016,"i":0.016,"o":0.0},"friendliai/meta-llama-3.1-70b-instruct":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"friendliai/meta-llama-3.1-8b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"ft:babbage-002":{"cr":0.16,"cw":1.5999999999999999,"i":1.5999999999999999,"o":1.5999999999999999},"ft:davinci-002":{"cr":1.2000000000000002,"cw":12.0,"i":12.0,"o":12.0},"ft:gpt-3.5-turbo":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":6.0},"ft:gpt-3.5-turbo-0125":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":6.0},"ft:gpt-3.5-turbo-0613":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":6.0},"ft:gpt-3.5-turbo-1106":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":6.0},"ft:gpt-4-0613":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"ft:gpt-4.1-2025-04-14":{"cr":0.75,"cw":3.0,"i":3.0,"o":12.0},"ft:gpt-4.1-mini-2025-04-14":{"cr":0.19999999999999998,"cw":0.7999999999999999,"i":0.7999999999999999,"o":3.1999999999999997},"ft:gpt-4.1-nano-2025-04-14":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.7999999999999999},"ft:gpt-4o-2024-08-06":{"cr":1.875,"cw":3.75,"i":3.75,"o":15.0},"ft:gpt-4o-2024-11-20":{"cr":0.375,"cw":1.875,"i":3.75,"o":15.0},"ft:gpt-4o-mini-2024-07-18":{"cr":0.15,"cw":0.3,"i":0.3,"o":1.2},"ft:o4-mini-2025-04-16":{"cr":1.0,"cw":4.0,"i":4.0,"o":16.0},"gemini-2.0-flash":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini-2.0-flash-001":{"cr":0.0375,"cw":0.15,"i":0.15,"o":0.6},"gemini-2.0-flash-exp-image-generation":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini-2.0-flash-lite":{"cr":0.01875,"cw":0.075,"i":0.075,"o":0.3},"gemini-2.0-flash-lite-001":{"cr":0.01875,"cw":0.075,"i":0.075,"o":0.3},"gemini-2.5-computer-use-preview-10-2025":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini-2.5-flash":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-image":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-lite":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini-2.5-flash-lite-preview-06-17":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini-2.5-flash-lite-preview-09-2025":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini-2.5-flash-native-audio-latest":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-native-audio-preview-09-2025":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-native-audio-preview-12-2025":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-preview-09-2025":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-flash-preview-tts":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-2.5-pro":{"cr":0.125,"cra":0.25,"cw":1.25,"cwa":0.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini-2.5-pro-preview-tts":{"cr":0.125,"cra":0.25,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini-3-flash-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"gemini-3-pro-image":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gemini-3-pro-image-preview":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gemini-3-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini-3.1-flash-image":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"gemini-3.1-flash-image-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"gemini-3.1-flash-lite":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini-3.1-flash-lite-preview":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini-3.1-flash-live-preview":{"cr":0.07500000000000001,"cw":0.75,"i":0.75,"o":4.5},"gemini-3.1-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini-3.1-pro-preview-customtools":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini-3.5-flash":{"cr":0.15,"cw":1.5,"i":1.5,"o":9.0},"gemini-embedding-001":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.0},"gemini-embedding-2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"gemini-embedding-2-preview":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"gemini-exp-1206":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-flash-experimental":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini-flash-latest":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini-flash-lite-latest":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini-live-2.5-flash-preview-native-audio-09-2025":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.0},"gemini-pro-latest":{"cr":0.125,"cra":0.25,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini-robotics-er-1.5-preview":{"cr":0.0,"cw":0.3,"i":0.3,"o":2.5},"gemini/deep-research-pro-preview-12-2025":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gemini/gemini-1.5-flash":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.0},"gemini/gemini-2.0-flash":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-2.0-flash-001":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-2.0-flash-exp-image-generation":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/gemini-2.0-flash-lite":{"cr":0.01875,"cw":0.075,"i":0.075,"o":0.3},"gemini/gemini-2.0-flash-lite-001":{"cr":0.01875,"cw":0.075,"i":0.075,"o":0.3},"gemini/gemini-2.5-computer-use-preview-10-2025":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini/gemini-2.5-flash":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-image":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-lite":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-2.5-flash-lite-preview-06-17":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-2.5-flash-lite-preview-09-2025":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-2.5-flash-native-audio-latest":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-native-audio-preview-09-2025":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-native-audio-preview-12-2025":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-preview-09-2025":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-flash-preview-tts":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-2.5-pro":{"cr":0.125,"cra":0.25,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini/gemini-2.5-pro-preview-tts":{"cr":0.125,"cra":0.25,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini/gemini-3-flash-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"gemini/gemini-3-pro-image":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gemini/gemini-3-pro-image-preview":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gemini/gemini-3-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini/gemini-3.1-flash-image":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini/gemini-3.1-flash-image-preview":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini/gemini-3.1-flash-lite":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini/gemini-3.1-flash-lite-preview":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"gemini/gemini-3.1-flash-live-preview":{"cr":0.07500000000000001,"cw":0.75,"i":0.75,"o":4.5},"gemini/gemini-3.1-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini/gemini-3.1-pro-preview-customtools":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"gemini/gemini-3.5-flash":{"cr":0.15,"cw":1.5,"i":1.5,"o":9.0},"gemini/gemini-embedding-001":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.0},"gemini/gemini-embedding-2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"gemini/gemini-embedding-2-preview":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"gemini/gemini-exp-1114":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/gemini-exp-1206":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/gemini-flash-latest":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemini-flash-lite-latest":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gemini/gemini-gemma-2-27b-it":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.0499999999999998},"gemini/gemini-gemma-2-9b-it":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.0499999999999998},"gemini/gemini-live-2.5-flash-preview-native-audio-09-2025":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.0},"gemini/gemini-pro-latest":{"cr":0.125,"cra":0.25,"cw":1.25,"i":1.25,"ia":2.5,"o":10.0,"oa":15.0},"gemini/gemini-robotics-er-1.5-preview":{"cr":0.0,"cw":0.3,"i":0.3,"o":2.5},"gemini/gemma-3-27b-it":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/learnlm-1.5-pro-experimental":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/lyria-3-clip-preview":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gemini/lyria-3-pro-preview":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/Embeddings":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/Embeddings-2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/EmbeddingsGigaR":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/GigaChat-2-Lite":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/GigaChat-2-Max":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"gigachat/GigaChat-2-Pro":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"github_copilot/mai-code-1-flash":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"github_copilot/mai-code-1-flash-internal":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"glm-4-7-251222":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"global.amazon.nova-2-lite-v1:0":{"cr":0.075,"cw":0.3,"i":0.3,"o":2.5},"global.anthropic.claude-fable-5":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"global.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"global.anthropic.claude-opus-4-5-20251101-v1:0":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"global.anthropic.claude-opus-4-6-v1":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"global.anthropic.claude-opus-4-7":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"global.anthropic.claude-opus-4-8":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"global.anthropic.claude-sonnet-4-20250514-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"global.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"global.anthropic.claude-sonnet-4-6":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"global.anthropic.claude-sonnet-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"gmi/MiniMaxAI/MiniMax-M2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"gmi/Qwen/Qwen3-VL-235B-A22B-Instruct-FP8":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.4},"gmi/anthropic/claude-opus-4":{"cr":1.5,"cw":15.0,"i":15.0,"o":75.0},"gmi/anthropic/claude-opus-4.5":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"gmi/anthropic/claude-sonnet-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"gmi/anthropic/claude-sonnet-4.5":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"gmi/deepseek-ai/DeepSeek-V3-0324":{"cr":0.028000000000000004,"cw":0.28,"i":0.28,"o":0.88},"gmi/deepseek-ai/DeepSeek-V3.2":{"cr":0.028000000000000004,"cw":0.28,"i":0.28,"o":0.39999999999999997},"gmi/google/gemini-3-flash-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"gmi/google/gemini-3-pro-preview":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"gmi/moonshotai/Kimi-K2-Thinking":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":1.2},"gmi/openai/gpt-4o":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gmi/openai/gpt-4o-mini":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"gmi/openai/gpt-5":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"gmi/openai/gpt-5.1":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"gmi/openai/gpt-5.2":{"cr":0.17500000000000002,"cw":1.75,"i":1.75,"o":14.0},"gmi/zai-org/GLM-4.7-FP8":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"google.gemma-3-12b-it":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.29},"google.gemma-3-27b-it":{"cr":0.023,"cw":0.22999999999999998,"i":0.22999999999999998,"o":0.38},"google.gemma-3-4b-it":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.08},"gpt-3.5-turbo":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"gpt-3.5-turbo-0125":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"gpt-3.5-turbo-1106":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":2.0},"gpt-3.5-turbo-16k":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.0},"gpt-3.5-turbo-instruct":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"gpt-3.5-turbo-instruct-0914":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"gpt-4":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"gpt-4-0125-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"gpt-4-0314":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"gpt-4-0613":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"gpt-4-1106-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"gpt-4-turbo":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"gpt-4-turbo-2024-04-09":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"gpt-4-turbo-preview":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"gpt-4.1":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"gpt-4.1-2025-04-14":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"gpt-4.1-mini":{"cr":0.09999999999999999,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"gpt-4.1-mini-2025-04-14":{"cr":0.09999999999999999,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"gpt-4.1-nano":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gpt-4.1-nano-2025-04-14":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"gpt-4o":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-2024-05-13":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"gpt-4o-2024-08-06":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-2024-11-20":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-audio-preview":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-audio-preview-2024-12-17":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-audio-preview-2025-06-03":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-mini":{"cr":0.075,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-2024-07-18":{"cr":0.075,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-audio-preview":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-audio-preview-2024-12-17":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-realtime-preview":{"cr":0.3,"cw":0.6,"i":0.6,"o":2.4},"gpt-4o-mini-realtime-preview-2024-12-17":{"cr":0.3,"cw":0.6,"i":0.6,"o":2.4},"gpt-4o-mini-search-preview":{"cr":0.075,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-search-preview-2025-03-11":{"cr":0.075,"cw":0.15,"i":0.15,"o":0.6},"gpt-4o-mini-transcribe":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":5.0},"gpt-4o-mini-transcribe-2025-03-20":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":5.0},"gpt-4o-mini-transcribe-2025-12-15":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":5.0},"gpt-4o-mini-tts":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-mini-tts-2025-03-20":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-mini-tts-2025-12-15":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-realtime-preview":{"cr":2.5,"cw":5.0,"i":5.0,"o":20.0},"gpt-4o-realtime-preview-2024-12-17":{"cr":2.5,"cw":5.0,"i":5.0,"o":20.0},"gpt-4o-realtime-preview-2025-06-03":{"cr":2.5,"cw":5.0,"i":5.0,"o":20.0},"gpt-4o-search-preview":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-search-preview-2025-03-11":{"cr":1.25,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-transcribe":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-4o-transcribe-diarize":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-5":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-2025-08-07":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-chat":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-chat-latest":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"gpt-5-mini-2025-08-07":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"gpt-5-nano":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"gpt-5-nano-2025-08-07":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"gpt-5-pro":{"cr":1.5,"cw":15.0,"i":15.0,"o":120.0},"gpt-5-pro-2025-10-06":{"cr":1.5,"cw":15.0,"i":15.0,"o":120.0},"gpt-5-search-api":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5-search-api-2025-10-14":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1-2025-11-13":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1-chat-latest":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1-codex-max":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"gpt-5.1-codex-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"gpt-5.2":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.2-2025-12-11":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.2-chat-latest":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.2-codex":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.2-pro":{"cr":2.0999999999999996,"cw":21.0,"i":21.0,"o":168.0},"gpt-5.2-pro-2025-12-11":{"cr":2.0999999999999996,"cw":21.0,"i":21.0,"o":168.0},"gpt-5.3-chat-latest":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.3-codex":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"gpt-5.4":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"gpt-5.4-2026-03-05":{"cr":0.25,"cw":2.5,"i":2.5,"o":15.0},"gpt-5.4-mini":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"gpt-5.4-mini-2026-03-17":{"cr":0.075,"cw":0.75,"i":0.75,"o":4.5},"gpt-5.4-nano":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"gpt-5.4-nano-2026-03-17":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.25},"gpt-5.4-pro":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"gpt-5.4-pro-2026-03-05":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"gpt-5.5":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"gpt-5.5-2026-04-23":{"cr":0.5,"cw":5.0,"i":5.0,"o":30.0},"gpt-5.5-pro":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"gpt-5.5-pro-2026-04-23":{"cr":3.0,"cw":30.0,"i":30.0,"o":180.0},"gpt-audio":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-audio-1.5":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-audio-2025-08-28":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"gpt-audio-mini":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gpt-audio-mini-2025-10-06":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gpt-audio-mini-2025-12-15":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gpt-image-1.5":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"gpt-image-1.5-2025-12-16":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"gpt-image-2":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"gpt-image-2-2026-04-21":{"cr":1.25,"cw":5.0,"i":5.0,"o":10.0},"gpt-realtime":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":16.0},"gpt-realtime-1.5":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":16.0},"gpt-realtime-2":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":16.0},"gpt-realtime-2025-08-28":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":16.0},"gpt-realtime-mini":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gpt-realtime-mini-2025-10-06":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gpt-realtime-mini-2025-12-15":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"gradient_ai/anthropic-claude-3-opus":{"cr":1.5,"cw":15.0,"i":15.0,"o":75.0},"gradient_ai/anthropic-claude-3.5-haiku":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":4.0},"gradient_ai/anthropic-claude-3.5-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"gradient_ai/anthropic-claude-3.7-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"gradient_ai/deepseek-r1-distill-llama-70b":{"cr":0.09900000000000002,"cw":0.9900000000000001,"i":0.9900000000000001,"o":0.9900000000000001},"gradient_ai/llama3-8b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"gradient_ai/llama3.3-70b-instruct":{"cr":0.065,"cw":0.65,"i":0.65,"o":0.65},"gradient_ai/mistral-nemo-instruct-2407":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"gradient_ai/openai-o3":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"gradient_ai/openai-o3-mini":{"cr":0.11,"cw":1.1,"i":1.1,"o":4.4},"groq/gemma-7b-it":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.08},"groq/llama-3.1-8b-instant":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.08},"groq/llama-3.3-70b-versatile":{"cr":0.059,"cw":0.59,"i":0.59,"o":0.7899999999999999},"groq/meta-llama/llama-4-maverick-17b-128e-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"groq/meta-llama/llama-4-scout-17b-16e-instruct":{"cr":0.011000000000000001,"cw":0.11,"i":0.11,"o":0.33999999999999997},"groq/meta-llama/llama-guard-4-12b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"groq/moonshotai/kimi-k2-instruct-0905":{"cr":0.5,"cw":1.0,"i":1.0,"o":3.0},"groq/openai/gpt-oss-120b":{"cr":0.075,"cw":0.15,"i":0.15,"o":0.6},"groq/openai/gpt-oss-20b":{"cr":0.0375,"cw":0.075,"i":0.075,"o":0.3},"groq/openai/gpt-oss-safeguard-20b":{"cr":0.037,"cw":0.075,"i":0.075,"o":0.3},"groq/qwen/qwen3-32b":{"cr":0.028999999999999998,"cw":0.29,"i":0.29,"o":0.59},"hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/Qwen/QwQ-32B":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"hyperbolic/Qwen/Qwen2.5-72B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/Qwen/Qwen3-235B-A22B":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"hyperbolic/deepseek-ai/DeepSeek-R1":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.39999999999999997},"hyperbolic/deepseek-ai/DeepSeek-R1-0528":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"hyperbolic/deepseek-ai/DeepSeek-V3":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"hyperbolic/deepseek-ai/DeepSeek-V3-0324":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.39999999999999997},"hyperbolic/meta-llama/Llama-3.2-3B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/meta-llama/Llama-3.3-70B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"hyperbolic/moonshotai/Kimi-K2-Instruct":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"inception/mercury-2":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.75},"j2-light":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":3.0},"j2-mid":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":10.0},"j2-ultra":{"cr":1.5,"cw":15.0,"i":15.0,"o":15.0},"jamba-1.5":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"jamba-1.5-large":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"jamba-1.5-large@001":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"jamba-1.5-mini":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"jamba-1.5-mini@001":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"jamba-large-1.6":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"jamba-large-1.7":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"jamba-mini-1.6":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"jamba-mini-1.7":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"jina-reranker-v2-base-multilingual":{"cr":0.0018,"cw":0.018,"i":0.018,"o":0.018},"jp.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.11,"cw":1.375,"i":1.1,"o":5.5},"jp.anthropic.claude-opus-4-7":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"jp.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.33,"cra":0.66,"cw":4.125,"cwa":8.25,"i":3.3000000000000003,"ia":6.6000000000000005,"o":16.5,"oa":24.75},"jp.anthropic.claude-sonnet-4-6":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"jp.anthropic.claude-sonnet-5":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"kimi-k2-thinking-251104":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"lambda_ai/deepseek-llama3.3-70b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"lambda_ai/deepseek-r1-0528":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"lambda_ai/deepseek-r1-671b":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"lambda_ai/deepseek-v3-0324":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"lambda_ai/hermes3-405b":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"lambda_ai/hermes3-70b":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"lambda_ai/hermes3-8b":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.04},"lambda_ai/lfm-40b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"lambda_ai/lfm-7b":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.04},"lambda_ai/llama-4-maverick-17b-128e-instruct-fp8":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"lambda_ai/llama-4-scout-17b-16e-instruct":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"lambda_ai/llama3.1-405b-instruct-fp8":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"lambda_ai/llama3.1-70b-instruct-fp8":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"lambda_ai/llama3.1-8b-instruct":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.04},"lambda_ai/llama3.1-nemotron-70b-instruct-fp8":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"lambda_ai/llama3.2-11b-vision-instruct":{"cr":0.0015,"cw":0.015,"i":0.015,"o":0.024999999999999998},"lambda_ai/llama3.2-3b-instruct":{"cr":0.0015,"cw":0.015,"i":0.015,"o":0.024999999999999998},"lambda_ai/llama3.3-70b-instruct-fp8":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.3},"lambda_ai/qwen25-coder-32b-instruct":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"lambda_ai/qwen3-32b-fp8":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"lemonade/Gemma-3-4b-it-GGUF":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"lemonade/Qwen3-4B-Instruct-2507-GGUF":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"lemonade/gpt-oss-120b-mxfp-GGUF":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"lemonade/gpt-oss-20b-mxfp4-GGUF":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"libertai/bge-m3":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.0},"libertai/deepseek-v4-flash":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.75},"libertai/deepseek-v4-flash-thinking":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.75},"libertai/gemma-4-31b-it":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.39999999999999997},"libertai/gemma-4-31b-it-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.39999999999999997},"libertai/hermes-3-8b-tee":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"libertai/qwen3.5-122b-a10b":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.75},"libertai/qwen3.5-122b-a10b-thinking":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.75},"libertai/qwen3.6-27b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.5},"libertai/qwen3.6-27b-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.5},"libertai/qwen3.6-35b-a3b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.5},"libertai/qwen3.6-35b-a3b-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.5},"llamagate/codellama-7b":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.12},"llamagate/deepseek-coder-6.7b":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.12},"llamagate/deepseek-r1-7b-qwen":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.15},"llamagate/deepseek-r1-8b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"llamagate/dolphin3-8b":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.15},"llamagate/gemma3-4b":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.08},"llamagate/llama-3.1-8b":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.049999999999999996},"llamagate/llama-3.2-3b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.08},"llamagate/llava-7b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"llamagate/mistral-7b-v0.3":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.15},"llamagate/nomic-embed-text":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"llamagate/openthinker-7b":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.15},"llamagate/qwen2.5-coder-7b":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.12},"llamagate/qwen3-8b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.14},"llamagate/qwen3-embedding-8b":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"llamagate/qwen3-vl-8b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.55},"meta.llama2-13b-chat-v1":{"cr":0.07500000000000001,"cw":0.75,"i":0.75,"o":1.0},"meta.llama2-70b-chat-v1":{"cr":0.195,"cw":1.95,"i":1.95,"o":2.56},"meta.llama3-1-405b-instruct-v1:0":{"cr":0.532,"cw":5.32,"i":5.32,"o":16.0},"meta.llama3-1-70b-instruct-v1:0":{"cr":0.09900000000000002,"cw":0.9900000000000001,"i":0.9900000000000001,"o":0.9900000000000001},"meta.llama3-1-8b-instruct-v1:0":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.22},"meta.llama3-2-11b-instruct-v1:0":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.35},"meta.llama3-2-1b-instruct-v1:0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"meta.llama3-2-3b-instruct-v1:0":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"meta.llama3-2-90b-instruct-v1:0":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"meta.llama3-3-70b-instruct-v1:0":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"meta.llama3-70b-instruct-v1:0":{"cr":0.265,"cw":2.65,"i":2.65,"o":3.5},"meta.llama3-8b-instruct-v1:0":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"meta.llama4-maverick-17b-instruct-v1:0":{"cr":0.024,"cw":0.24,"i":0.24,"o":0.9700000000000001},"meta.llama4-scout-17b-instruct-v1:0":{"cr":0.017,"cw":0.16999999999999998,"i":0.16999999999999998,"o":0.66},"minimax.minimax-m2":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"minimax.minimax-m2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"minimax.minimax-m2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"minimax/MiniMax-M2":{"cr":0.03,"cw":0.375,"i":0.3,"o":1.2},"minimax/MiniMax-M2.1":{"cr":0.03,"cw":0.375,"i":0.3,"o":1.2},"minimax/MiniMax-M2.1-lightning":{"cr":0.03,"cw":0.375,"i":0.3,"o":2.4},"minimax/MiniMax-M2.5":{"cr":0.03,"cw":0.375,"i":0.3,"o":1.2},"minimax/MiniMax-M2.5-lightning":{"cr":0.03,"cw":0.375,"i":0.3,"o":2.4},"minimax/MiniMax-M3":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"mistral.devstral-2-123b":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral.magistral-small-2509":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral.ministral-3-14b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"mistral.ministral-3-3b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"mistral.ministral-3-8b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"mistral.mistral-7b-instruct-v0:2":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.19999999999999998},"mistral.mistral-large-2402-v1:0":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"mistral.mistral-large-2407-v1:0":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":9.0},"mistral.mistral-large-3-675b-instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral.mistral-small-2402-v1:0":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"mistral.mixtral-8x7b-instruct-v0:1":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":0.7},"mistral.voxtral-mini-3b-2507":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.04},"mistral.voxtral-small-24b-2507":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/codestral-2405":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"mistral/codestral-2508":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"mistral/codestral-latest":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"mistral/codestral-mamba-latest":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"mistral/devstral-2512":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/devstral-latest":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/devstral-medium-2507":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/devstral-medium-latest":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/devstral-small-2505":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/devstral-small-2507":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/devstral-small-latest":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/labs-devstral-small-2512":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/magistral-medium-1-2-2509":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"mistral/magistral-medium-2506":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"mistral/magistral-medium-2509":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"mistral/magistral-medium-latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"mistral/magistral-small-1-2-2509":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/magistral-small-2506":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/magistral-small-latest":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/ministral-3-14b-2512":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"mistral/ministral-3-3b-2512":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"mistral/ministral-3-8b-2512":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"mistral/ministral-8b-2512":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"mistral/ministral-8b-latest":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"mistral/mistral-large-2402":{"cr":0.39999999999999997,"cw":4.0,"i":4.0,"o":12.0},"mistral/mistral-large-2407":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":9.0},"mistral/mistral-large-2411":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"mistral/mistral-large-2512":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/mistral-large-3":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/mistral-large-latest":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"mistral/mistral-medium":{"cr":0.27,"cw":2.7,"i":2.7,"o":8.1},"mistral/mistral-medium-2312":{"cr":0.27,"cw":2.7,"i":2.7,"o":8.1},"mistral/mistral-medium-2505":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/mistral-medium-2508":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/mistral-medium-2604":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":7.5},"mistral/mistral-medium-3-1-2508":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"mistral/mistral-medium-3-5":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":7.5},"mistral/mistral-medium-latest":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":7.5},"mistral/mistral-small":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"mistral/mistral-small-3-2-2506":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.18},"mistral/mistral-small-latest":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.18},"mistral/mistral-tiny":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"mistral/open-codestral-mamba":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"mistral/open-mistral-7b":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.25},"mistral/open-mistral-nemo":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"mistral/open-mistral-nemo-2407":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"mistral/open-mixtral-8x22b":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"mistral/open-mixtral-8x7b":{"cr":0.07,"cw":0.7,"i":0.7,"o":0.7},"mistral/pixtral-12b-2409":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"mistral/pixtral-large-2411":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"mistral/pixtral-large-latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"moonshot.kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"moonshot/kimi-k2-0711-preview":{"cr":0.15,"cw":0.6,"i":0.6,"o":2.5},"moonshot/kimi-k2-0905-preview":{"cr":0.15,"cw":0.6,"i":0.6,"o":2.5},"moonshot/kimi-k2-thinking":{"cr":0.15,"cw":0.6,"i":0.6,"o":2.5},"moonshot/kimi-k2-thinking-turbo":{"cr":0.15,"cw":1.15,"i":1.15,"o":8.0},"moonshot/kimi-k2-turbo-preview":{"cr":0.15,"cw":1.15,"i":1.15,"o":8.0},"moonshot/kimi-k2.5":{"cr":0.09999999999999999,"cw":0.6,"i":0.6,"o":3.0},"moonshot/kimi-k2.6":{"cr":0.16,"cw":0.95,"i":0.95,"o":4.0},"moonshot/kimi-latest":{"cr":0.15,"cw":2.0,"i":2.0,"o":5.0},"moonshot/kimi-latest-128k":{"cr":0.15,"cw":2.0,"i":2.0,"o":5.0},"moonshot/kimi-latest-32k":{"cr":0.15,"cw":1.0,"i":1.0,"o":3.0},"moonshot/kimi-latest-8k":{"cr":0.15,"cw":0.19999999999999998,"i":0.19999999999999998,"o":2.0},"moonshot/kimi-thinking-preview":{"cr":0.15,"cw":0.6,"i":0.6,"o":2.5},"moonshot/moonshot-v1-128k":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"moonshot/moonshot-v1-128k-0430":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"moonshot/moonshot-v1-128k-vision-preview":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"moonshot/moonshot-v1-32k":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"moonshot/moonshot-v1-32k-0430":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"moonshot/moonshot-v1-32k-vision-preview":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"moonshot/moonshot-v1-8k":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":2.0},"moonshot/moonshot-v1-8k-0430":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":2.0},"moonshot/moonshot-v1-8k-vision-preview":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":2.0},"moonshot/moonshot-v1-auto":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"moonshotai.kimi-k2.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.0},"morph/morph-v3-fast":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":1.2},"morph/morph-v3-large":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":1.9},"multimodalembedding":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.0},"multimodalembedding@001":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.0},"nebius/BAAI/bge-en-icl":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.0},"nebius/BAAI/bge-multilingual-gemma2":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.0},"nebius/NousResearch/Hermes-3-Llama-3.1-405B":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"nebius/Qwen/QwQ-32B":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.44999999999999996},"nebius/Qwen/Qwen2-VL-72B-Instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"nebius/Qwen/Qwen2-VL-7B-Instruct":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.06},"nebius/Qwen/Qwen2.5-32B-Instruct":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.19999999999999998},"nebius/Qwen/Qwen2.5-72B-Instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"nebius/Qwen/Qwen2.5-Coder-7B":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.03},"nebius/Qwen/Qwen2.5-VL-72B-Instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"nebius/Qwen/Qwen3-14B":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.24},"nebius/Qwen/Qwen3-235B-A22B":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"nebius/Qwen/Qwen3-30B-A3B":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"nebius/Qwen/Qwen3-32B":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"nebius/Qwen/Qwen3-4B":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.24},"nebius/deepseek-ai/DeepSeek-R1":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"nebius/deepseek-ai/DeepSeek-R1-0528":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.4},"nebius/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.75},"nebius/deepseek-ai/DeepSeek-V3":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"nebius/deepseek-ai/DeepSeek-V3-0324":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"nebius/google/gemma-3-27b-it":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.19999999999999998},"nebius/intfloat/e5-mistral-7b-instruct":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.0},"nebius/meta-llama/Llama-3.3-70B-Instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"nebius/meta-llama/Llama-Guard-3-8B":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.06},"nebius/meta-llama/Meta-Llama-3.1-405B-Instruct":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"nebius/meta-llama/Meta-Llama-3.1-70B-Instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.39999999999999997},"nebius/meta-llama/Meta-Llama-3.1-8B-Instruct":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.06},"nebius/mistralai/Mistral-Nemo-Instruct-2407":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.12},"nebius/nvidia/Llama-3.1-Nemotron-Ultra-253B-v1":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.7999999999999998},"nebius/nvidia/Llama-3.3-Nemotron-Super-49B-v1":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"novita/Sao10K/L3-8B-Stheno-v3.2":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.049999999999999996},"novita/baai/bge-m3":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.01},"novita/baai/bge-reranker-v2-m3":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.01},"novita/baichuan/baichuan-m2-32b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.07},"novita/baidu/ernie-4.5-21B-a3b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"novita/baidu/ernie-4.5-21B-a3b-thinking":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"novita/baidu/ernie-4.5-300b-a47b-paddle":{"cr":0.028000000000000004,"cw":0.28,"i":0.28,"o":1.1},"novita/baidu/ernie-4.5-vl-28b-a3b":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.56},"novita/baidu/ernie-4.5-vl-28b-a3b-thinking":{"cr":0.03900000000000001,"cw":0.39,"i":0.39,"o":0.39},"novita/baidu/ernie-4.5-vl-424b-a47b":{"cr":0.042,"cw":0.42,"i":0.42,"o":1.25},"novita/deepseek/deepseek-ocr":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.03},"novita/deepseek/deepseek-prover-v2-671b":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.5},"novita/deepseek/deepseek-r1-0528":{"cr":0.35,"cw":0.7,"i":0.7,"o":2.5},"novita/deepseek/deepseek-r1-0528-qwen3-8b":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.09},"novita/deepseek/deepseek-r1-distill-llama-70b":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"novita/deepseek/deepseek-r1-distill-qwen-14b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"novita/deepseek/deepseek-r1-distill-qwen-32b":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"novita/deepseek/deepseek-r1-turbo":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.5},"novita/deepseek/deepseek-v3-0324":{"cr":0.135,"cw":0.27,"i":0.27,"o":1.12},"novita/deepseek/deepseek-v3-turbo":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.3},"novita/deepseek/deepseek-v3.1":{"cr":0.135,"cw":0.27,"i":0.27,"o":1.0},"novita/deepseek/deepseek-v3.1-terminus":{"cr":0.135,"cw":0.27,"i":0.27,"o":1.0},"novita/deepseek/deepseek-v3.2":{"cr":0.13449999999999998,"cw":0.26899999999999996,"i":0.26899999999999996,"o":0.39999999999999997},"novita/deepseek/deepseek-v3.2-exp":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":0.41},"novita/google/gemma-3-12b-it":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.09999999999999999},"novita/google/gemma-3-27b-it":{"cr":0.0119,"cw":0.119,"i":0.119,"o":0.19999999999999998},"novita/gryphe/mythomax-l2-13b":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.09},"novita/kwaipilot/kat-coder-pro":{"cr":0.06,"cw":0.3,"i":0.3,"o":1.2},"novita/meta-llama/llama-3-70b-instruct":{"cr":0.051,"cw":0.51,"i":0.51,"o":0.74},"novita/meta-llama/llama-3-8b-instruct":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.04},"novita/meta-llama/llama-3.1-8b-instruct":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.049999999999999996},"novita/meta-llama/llama-3.2-3b-instruct":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.049999999999999996},"novita/meta-llama/llama-3.3-70b-instruct":{"cr":0.013500000000000002,"cw":0.135,"i":0.135,"o":0.39999999999999997},"novita/meta-llama/llama-4-maverick-17b-128e-instruct-fp8":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":0.85},"novita/meta-llama/llama-4-scout-17b-16e-instruct":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.59},"novita/microsoft/wizardlm-2-8x22b":{"cr":0.062,"cw":0.62,"i":0.62,"o":0.62},"novita/minimax/minimax-m2":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"novita/minimax/minimax-m2.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"novita/minimaxai/minimax-m1-80k":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.2},"novita/mistralai/mistral-nemo":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.16999999999999998},"novita/moonshotai/kimi-k2-0905":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"novita/moonshotai/kimi-k2-instruct":{"cr":0.05700000000000001,"cw":0.5700000000000001,"i":0.5700000000000001,"o":2.3},"novita/moonshotai/kimi-k2-thinking":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"novita/nousresearch/hermes-2-pro-llama-3-8b":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.14},"novita/openai/gpt-oss-120b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"novita/openai/gpt-oss-20b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.15},"novita/paddlepaddle/paddleocr-vl":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.02},"novita/qwen/qwen-2.5-72b-instruct":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":0.39999999999999997},"novita/qwen/qwen-mt-plus":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.75},"novita/qwen/qwen2.5-7b-instruct":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.07},"novita/qwen/qwen2.5-vl-72b-instruct":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"novita/qwen/qwen3-235b-a22b-fp8":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.7999999999999999},"novita/qwen/qwen3-235b-a22b-instruct-2507":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.58},"novita/qwen/qwen3-235b-a22b-thinking-2507":{"cr":0.03,"cw":0.3,"i":0.3,"o":3.0},"novita/qwen/qwen3-30b-a3b-fp8":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.44999999999999996},"novita/qwen/qwen3-32b-fp8":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.44999999999999996},"novita/qwen/qwen3-4b-fp8":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.03},"novita/qwen/qwen3-8b-fp8":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.13799999999999998},"novita/qwen/qwen3-coder-30b-a3b-instruct":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.27},"novita/qwen/qwen3-coder-480b-a35b-instruct":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.3},"novita/qwen/qwen3-embedding-0.6b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.0},"novita/qwen/qwen3-embedding-8b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.0},"novita/qwen/qwen3-max":{"cr":0.21100000000000002,"cw":2.1100000000000003,"i":2.1100000000000003,"o":8.450000000000001},"novita/qwen/qwen3-next-80b-a3b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.5},"novita/qwen/qwen3-next-80b-a3b-thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.5},"novita/qwen/qwen3-omni-30b-a3b-instruct":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.9700000000000001},"novita/qwen/qwen3-omni-30b-a3b-thinking":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.9700000000000001},"novita/qwen/qwen3-reranker-8b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.049999999999999996},"novita/qwen/qwen3-vl-235b-a22b-instruct":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.5},"novita/qwen/qwen3-vl-235b-a22b-thinking":{"cr":0.098,"cw":0.98,"i":0.98,"o":3.95},"novita/qwen/qwen3-vl-30b-a3b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.7},"novita/qwen/qwen3-vl-30b-a3b-thinking":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.0},"novita/qwen/qwen3-vl-8b-instruct":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.5},"novita/sao10k/l3-70b-euryale-v2.1":{"cr":0.148,"cw":1.48,"i":1.48,"o":1.48},"novita/sao10k/l3-8b-lunaris":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.049999999999999996},"novita/sao10k/l31-70b-euryale-v2.2":{"cr":0.148,"cw":1.48,"i":1.48,"o":1.48},"novita/skywork/r1v4-lite":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"novita/xiaomimimo/mimo-v2-flash":{"cr":0.02,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"novita/zai-org/autoglm-phone-9b-multilingual":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.13799999999999998},"novita/zai-org/glm-4.5":{"cr":0.11,"cw":0.6,"i":0.6,"o":2.2},"novita/zai-org/glm-4.5-air":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.85},"novita/zai-org/glm-4.5v":{"cr":0.11,"cw":0.6,"i":0.6,"o":1.7999999999999998},"novita/zai-org/glm-4.6":{"cr":0.11,"cw":0.55,"i":0.55,"o":2.2},"novita/zai-org/glm-4.6v":{"cr":0.055,"cw":0.3,"i":0.3,"o":0.8999999999999999},"novita/zai-org/glm-4.7":{"cr":0.11,"cw":0.6,"i":0.6,"o":2.2},"nscale/Qwen/QwQ-32B":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.19999999999999998},"nscale/Qwen/Qwen2.5-Coder-32B-Instruct":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.19999999999999998},"nscale/Qwen/Qwen2.5-Coder-3B-Instruct":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.03},"nscale/Qwen/Qwen2.5-Coder-7B-Instruct":{"cr":0.001,"cw":0.01,"i":0.01,"o":0.03},"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B":{"cr":0.037500000000000006,"cw":0.375,"i":0.375,"o":0.375},"nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.024999999999999998},"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.09},"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.07},"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"nscale/meta-llama/Llama-3.1-8B-Instruct":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.03},"nscale/meta-llama/Llama-3.3-70B-Instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.29},"nscale/mistralai/mixtral-8x22b-instruct-v0.1":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"nvidia.nemotron-nano-12b-v2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"nvidia.nemotron-nano-3-30b":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"nvidia.nemotron-nano-9b-v2":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.22999999999999998},"nvidia.nemotron-super-3-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.65},"nvidia_nim/nvidia/llama-3_2-nv-rerankqa-1b-v2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"nvidia_nim/ranking/nvidia/llama-3.2-nv-rerankqa-1b-v2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"o1":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"o1-2024-12-17":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"o1-pro":{"cr":14.999999999999998,"cw":150.0,"i":150.0,"o":600.0},"o1-pro-2025-03-19":{"cr":14.999999999999998,"cw":150.0,"i":150.0,"o":600.0},"o3":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"o3-2025-04-16":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"o3-deep-research":{"cr":2.5,"cw":10.0,"i":10.0,"o":40.0},"o3-deep-research-2025-06-26":{"cr":2.5,"cw":10.0,"i":10.0,"o":40.0},"o3-mini":{"cr":0.55,"cw":1.1,"i":1.1,"o":4.4},"o3-mini-2025-01-31":{"cr":0.55,"cw":1.1,"i":1.1,"o":4.4},"o3-pro":{"cr":2.0000000000000004,"cw":20.0,"i":20.0,"o":80.0},"o3-pro-2025-06-10":{"cr":2.0000000000000004,"cw":20.0,"i":20.0,"o":80.0},"o4-mini":{"cr":0.275,"cw":1.1,"i":1.1,"o":4.4},"o4-mini-2025-04-16":{"cr":0.275,"cw":1.1,"i":1.1,"o":4.4},"o4-mini-deep-research":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"o4-mini-deep-research-2025-06-26":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"oci/cohere.command-a-03-2025":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-a-reasoning":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-a-reasoning-08-2025":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-a-translate-08-2025":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.09},"oci/cohere.command-a-vision":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-a-vision-07-2025":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-latest":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-plus-latest":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.command-r-08-2024":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"oci/cohere.command-r-plus-08-2024":{"cr":0.15600000000000003,"cw":1.56,"i":1.56,"o":1.56},"oci/cohere.embed-english-image-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-english-light-image-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-english-light-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-english-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-multilingual-light-image-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-multilingual-light-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-multilingual-v3.0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"oci/cohere.embed-v4.0":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"oci/google.gemini-2.5-flash":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"oci/google.gemini-2.5-flash-lite":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"oci/google.gemini-2.5-pro":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"oci/meta.llama-3.1-405b-instruct":{"cr":1.068,"cw":10.68,"i":10.68,"o":10.68},"oci/meta.llama-3.1-70b-instruct":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/meta.llama-3.1-8b-instruct":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/meta.llama-3.2-11b-vision-instruct":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"oci/meta.llama-3.2-90b-vision-instruct":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"oci/meta.llama-3.3-70b-instruct":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/meta.llama-3.3-70b-instruct-fp8-dynamic":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/meta.llama-4-maverick-17b-128e-instruct-fp8":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/meta.llama-4-scout-17b-16e-instruct":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"oci/openai.gpt-5":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"oci/openai.gpt-5-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"oci/openai.gpt-5-nano":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"oci/xai.grok-3":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"oci/xai.grok-3-fast":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"oci/xai.grok-3-mini":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.5},"oci/xai.grok-3-mini-fast":{"cr":0.06,"cw":0.6,"i":0.6,"o":4.0},"oci/xai.grok-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"oci/xai.grok-4-fast":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"oci/xai.grok-4.1-fast":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"oci/xai.grok-4.20":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"oci/xai.grok-4.20-multi-agent":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"oci/xai.grok-code-fast-1":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"ollama/codegeex4":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/codegemma":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/codellama":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/deepseek-coder-v2-base":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/deepseek-coder-v2-instruct":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/deepseek-coder-v2-lite-base":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/deepseek-coder-v2-lite-instruct":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/deepseek-v3.1:671b-cloud":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/gpt-oss:120b-cloud":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/gpt-oss:20b-cloud":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/internlm2_5-20b-chat":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama2-uncensored":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama2:13b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama2:70b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama2:7b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama3":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama3.1":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama3:70b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/llama3:8b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mistral":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mistral-7B-Instruct-v0.1":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mistral-7B-Instruct-v0.2":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mistral-large-instruct-2407":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mixtral-8x22B-Instruct-v0.1":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/mixtral-8x7B-Instruct-v0.1":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/orca-mini":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/qwen3-coder:480b-cloud":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"ollama/vicuna":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"omni-moderation-2024-09-26":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"omni-moderation-latest":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"openai.gpt-oss-120b-1:0":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"openai.gpt-oss-20b-1:0":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.3},"openai.gpt-oss-safeguard-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"openai.gpt-oss-safeguard-20b":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.19999999999999998},"openrouter/anthropic/claude-3-haiku":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.25},"openrouter/anthropic/claude-3.5-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"openrouter/anthropic/claude-3.7-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"openrouter/anthropic/claude-haiku-4.5":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"openrouter/anthropic/claude-opus-4":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"openrouter/anthropic/claude-opus-4.1":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"openrouter/anthropic/claude-opus-4.5":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"openrouter/anthropic/claude-opus-4.6":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"openrouter/anthropic/claude-opus-4.7":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"openrouter/anthropic/claude-sonnet-4":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"openrouter/anthropic/claude-sonnet-4.5":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"openrouter/anthropic/claude-sonnet-4.6":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"openrouter/bytedance/ui-tars-1.5-7b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"openrouter/deepseek/deepseek-chat":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.28},"openrouter/deepseek/deepseek-chat-v3-0324":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.28},"openrouter/deepseek/deepseek-chat-v3.1":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.7999999999999999},"openrouter/deepseek/deepseek-r1":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"openrouter/deepseek/deepseek-r1-0528":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":2.1500000000000004},"openrouter/deepseek/deepseek-v3.2":{"cr":0.028000000000000004,"cw":0.28,"i":0.28,"o":0.39999999999999997},"openrouter/deepseek/deepseek-v3.2-exp":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"openrouter/google/gemini-2.0-flash-001":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"openrouter/google/gemini-2.5-flash":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"openrouter/google/gemini-2.5-pro":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"openrouter/google/gemini-3-flash-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"openrouter/google/gemini-3-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"openrouter/google/gemini-3.1-flash-lite":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"openrouter/google/gemini-3.1-flash-lite-preview":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"openrouter/google/gemini-3.1-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"openrouter/gryphe/mythomax-l2-13b":{"cr":0.1875,"cw":1.875,"i":1.875,"o":1.875},"openrouter/mancer/weaver":{"cr":0.5625,"cw":5.625,"i":5.625,"o":5.625},"openrouter/meta-llama/llama-3-70b-instruct":{"cr":0.059,"cw":0.59,"i":0.59,"o":0.7899999999999999},"openrouter/minimax/minimax-m2":{"cr":0.0255,"cw":0.255,"i":0.255,"o":1.02},"openrouter/minimax/minimax-m2.1":{"cr":0.0,"cw":0.0,"i":0.27,"o":1.2},"openrouter/minimax/minimax-m2.5":{"cr":0.15,"cw":0.3,"i":0.3,"o":1.1},"openrouter/mistralai/devstral-2512":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"openrouter/mistralai/ministral-14b-2512":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"openrouter/mistralai/ministral-3b-2512":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"openrouter/mistralai/ministral-8b-2512":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"openrouter/mistralai/mistral-7b-instruct":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.13},"openrouter/mistralai/mistral-large":{"cr":0.7999999999999999,"cw":8.0,"i":8.0,"o":24.0},"openrouter/mistralai/mistral-large-2512":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"openrouter/mistralai/mistral-small-3.1-24b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"openrouter/mistralai/mistral-small-3.2-24b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"openrouter/mistralai/mixtral-8x22b-instruct":{"cr":0.065,"cw":0.65,"i":0.65,"o":0.65},"openrouter/moonshotai/kimi-k2.5":{"cr":0.09999999999999999,"cw":0.6,"i":0.6,"o":3.0},"openrouter/openai/gpt-3.5-turbo":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"openrouter/openai/gpt-3.5-turbo-16k":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.0},"openrouter/openai/gpt-4":{"cr":3.0,"cw":30.0,"i":30.0,"o":60.0},"openrouter/openai/gpt-4.1":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"openrouter/openai/gpt-4.1-mini":{"cr":0.09999999999999999,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"openrouter/openai/gpt-4.1-nano":{"cr":0.024999999999999998,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"openrouter/openai/gpt-4o":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"openrouter/openai/gpt-4o-2024-05-13":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"openrouter/openai/gpt-5":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"openrouter/openai/gpt-5-chat":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"openrouter/openai/gpt-5-codex":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"openrouter/openai/gpt-5-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"openrouter/openai/gpt-5-nano":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"openrouter/openai/gpt-5.1-codex-max":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"openrouter/openai/gpt-5.2":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"openrouter/openai/gpt-5.2-chat":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"openrouter/openai/gpt-5.2-codex":{"cr":0.175,"cw":1.75,"i":1.75,"o":14.0},"openrouter/openai/gpt-5.2-pro":{"cr":2.0999999999999996,"cw":21.0,"i":21.0,"o":168.0},"openrouter/openai/gpt-oss-120b":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.7999999999999999},"openrouter/openai/gpt-oss-20b":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.09999999999999999},"openrouter/openai/o1":{"cr":7.5,"cw":15.0,"i":15.0,"o":60.0},"openrouter/openai/o3-mini":{"cr":0.11,"cw":1.1,"i":1.1,"o":4.4},"openrouter/openai/o3-mini-high":{"cr":0.11,"cw":1.1,"i":1.1,"o":4.4},"openrouter/openrouter/auto":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"openrouter/openrouter/bodybuilder":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"openrouter/openrouter/free":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"openrouter/qwen/qwen-2.5-coder-32b-instruct":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.18},"openrouter/qwen/qwen-vl-plus":{"cr":0.021,"cw":0.21,"i":0.21,"o":0.63},"openrouter/qwen/qwen3-235b-a22b-2507":{"cr":0.0071,"cw":0.071,"i":0.071,"o":0.09999999999999999},"openrouter/qwen/qwen3-235b-a22b-thinking-2507":{"cr":0.011000000000000001,"cw":0.11,"i":0.11,"o":0.6},"openrouter/qwen/qwen3-coder":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.95},"openrouter/qwen/qwen3-coder-plus":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"openrouter/qwen/qwen3.5-122b-a10b":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"openrouter/qwen/qwen3.5-27b":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.4},"openrouter/qwen/qwen3.5-35b-a3b":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"openrouter/qwen/qwen3.5-397b-a17b":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.5999999999999996},"openrouter/qwen/qwen3.5-flash-02-23":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"openrouter/qwen/qwen3.5-plus-02-15":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.4},"openrouter/qwen/qwen3.6-plus":{"cr":0.0325,"cw":0.325,"i":0.325,"o":1.95},"openrouter/switchpoint/router":{"cr":0.085,"cw":0.85,"i":0.85,"o":3.4},"openrouter/undi95/remm-slerp-l2-13b":{"cr":0.1875,"cw":1.875,"i":1.875,"o":1.875},"openrouter/x-ai/grok-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"openrouter/xiaomi/mimo-v2-flash":{"cr":0.01,"cw":0.0,"i":0.09999999999999999,"o":0.3},"openrouter/xiaomi/mimo-v2.5":{"cr":0.08,"cw":0.0,"i":0.39999999999999997,"o":2.0},"openrouter/xiaomi/mimo-v2.5-pro":{"cr":0.19999999999999998,"cw":0.0,"i":1.0,"o":3.0},"openrouter/z-ai/glm-4.6":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.75},"openrouter/z-ai/glm-4.6:exacto":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":1.9},"openrouter/z-ai/glm-4.7":{"cr":0.0,"cw":0.0,"i":0.39999999999999997,"o":1.5},"openrouter/z-ai/glm-4.7-flash":{"cr":0.0,"cw":0.0,"i":0.07,"o":0.39999999999999997},"openrouter/z-ai/glm-5":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":2.56},"openrouter/z-ai/glm-5.1":{"cr":0.5249999999999999,"cw":0.0,"i":1.0499999999999998,"o":3.5},"ovhcloud/DeepSeek-R1-Distill-Llama-70B":{"cr":0.067,"cw":0.67,"i":0.67,"o":0.67},"ovhcloud/Llama-3.1-8B-Instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"ovhcloud/Meta-Llama-3_1-70B-Instruct":{"cr":0.067,"cw":0.67,"i":0.67,"o":0.67},"ovhcloud/Meta-Llama-3_3-70B-Instruct":{"cr":0.067,"cw":0.67,"i":0.67,"o":0.67},"ovhcloud/Mistral-7B-Instruct-v0.3":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"ovhcloud/Mistral-Nemo-Instruct-2407":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.13},"ovhcloud/Mistral-Small-3.2-24B-Instruct-2506":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.28},"ovhcloud/Mixtral-8x7B-Instruct-v0.1":{"cr":0.06300000000000001,"cw":0.63,"i":0.63,"o":0.63},"ovhcloud/Qwen2.5-Coder-32B-Instruct":{"cr":0.08700000000000001,"cw":0.87,"i":0.87,"o":0.87},"ovhcloud/Qwen2.5-VL-72B-Instruct":{"cr":0.09100000000000001,"cw":0.9099999999999999,"i":0.9099999999999999,"o":0.9099999999999999},"ovhcloud/Qwen3-32B":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.22999999999999998},"ovhcloud/gpt-oss-120b":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.39999999999999997},"ovhcloud/gpt-oss-20b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.15},"ovhcloud/llava-v1.6-mistral-7b-hf":{"cr":0.028999999999999998,"cw":0.29,"i":0.29,"o":0.29},"ovhcloud/mamba-codestral-7B-v0.1":{"cr":0.019000000000000003,"cw":0.19,"i":0.19,"o":0.19},"palm/chat-bison":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"palm/chat-bison-001":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"palm/text-bison":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"palm/text-bison-001":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"palm/text-bison-safety-off":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"palm/text-bison-safety-recitation-off":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.125},"perplexity/codellama-34b-instruct":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.4},"perplexity/codellama-70b-instruct":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.8},"perplexity/llama-2-70b-chat":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.8},"perplexity/llama-3.1-70b-instruct":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"perplexity/llama-3.1-8b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"perplexity/mistral-7b-instruct":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"perplexity/mixtral-8x7b-instruct":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"perplexity/pplx-70b-chat":{"cr":0.07,"cw":0.7,"i":0.7,"o":2.8},"perplexity/pplx-70b-online":{"cr":0.0,"cw":0.0,"i":0.0,"o":2.8},"perplexity/pplx-7b-chat":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"perplexity/pplx-7b-online":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.28},"perplexity/pplx-embed-v1-0.6b":{"cr":0.0004000000000000001,"cw":0.004,"i":0.004,"o":0.0},"perplexity/pplx-embed-v1-4b":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.0},"perplexity/sonar":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"perplexity/sonar-deep-research":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"perplexity/sonar-medium-chat":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.7999999999999998},"perplexity/sonar-medium-online":{"cr":0.0,"cw":0.0,"i":0.0,"o":1.7999999999999998},"perplexity/sonar-pro":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"perplexity/sonar-reasoning":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"perplexity/sonar-reasoning-pro":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"perplexity/sonar-small-chat":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"perplexity/sonar-small-online":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.28},"pinstripes/ps/deepseek-v4-flash":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"pinstripes/ps/glm-4.5-air":{"cr":0.012499999999999999,"cw":0.125,"i":0.125,"o":0.44999999999999996},"pinstripes/ps/minimax-m2.7":{"cr":0.0255,"cw":0.255,"i":0.255,"o":0.55},"pinstripes/ps/qwen3-30b-a3b":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.19999999999999998},"pinstripes/ps/qwen3-coder-30b-a3b":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.6},"pinstripes/ps/qwen3.6-35b-a3b":{"cr":0.014000000000000002,"cw":0.14,"i":0.14,"o":0.44999999999999996},"publicai/BSC-LT/ALIA-40b-instruct_Q8_0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/BSC-LT/salamandra-7b-instruct-tools-16k":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/aisingapore/Gemma-SEA-LION-v4-27B-IT":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/aisingapore/Qwen-SEA-LION-v4-32B-IT":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/allenai/Olmo-3-32B-Think":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/allenai/Olmo-3-7B-Instruct":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/allenai/Olmo-3-7B-Think":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/swiss-ai/apertus-70b-instruct":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"publicai/swiss-ai/apertus-8b-instruct":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"qwen.qwen3-235b-a22b-2507-v1:0":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.88},"qwen.qwen3-32b-v1:0":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"qwen.qwen3-coder-30b-a3b-v1:0":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"qwen.qwen3-coder-480b-a35b-v1:0":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":1.7999999999999998},"qwen.qwen3-coder-next":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.2},"qwen.qwen3-next-80b-a3b":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.2},"qwen.qwen3-vl-235b-a22b":{"cr":0.053000000000000005,"cw":0.53,"i":0.53,"o":2.66},"replicate/anthropic/claude-3.5-haiku":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"replicate/anthropic/claude-3.5-sonnet":{"cr":0.375,"cw":3.75,"i":3.75,"o":18.75},"replicate/anthropic/claude-3.7-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"replicate/anthropic/claude-4-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"replicate/anthropic/claude-4.5-haiku":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"replicate/anthropic/claude-4.5-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"replicate/deepseek-ai/deepseek-r1":{"cr":0.375,"cw":3.75,"i":3.75,"o":10.0},"replicate/deepseek-ai/deepseek-v3":{"cr":0.14500000000000002,"cw":1.4500000000000002,"i":1.4500000000000002,"o":1.4500000000000002},"replicate/deepseek-ai/deepseek-v3.1":{"cr":0.06720000000000001,"cw":0.6719999999999999,"i":0.6719999999999999,"o":2.016},"replicate/google/gemini-2.5-flash":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":2.5},"replicate/google/gemini-3-pro":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"replicate/ibm-granite/granite-3.3-8b-instruct":{"cr":0.003,"cw":0.03,"i":0.03,"o":0.25},"replicate/meta/llama-2-13b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.5},"replicate/meta/llama-2-13b-chat":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.5},"replicate/meta/llama-2-70b":{"cr":0.065,"cw":0.65,"i":0.65,"o":2.75},"replicate/meta/llama-2-70b-chat":{"cr":0.065,"cw":0.65,"i":0.65,"o":2.75},"replicate/meta/llama-2-7b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/meta/llama-2-7b-chat":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/meta/llama-3-70b":{"cr":0.065,"cw":0.65,"i":0.65,"o":2.75},"replicate/meta/llama-3-70b-instruct":{"cr":0.065,"cw":0.65,"i":0.65,"o":2.75},"replicate/meta/llama-3-8b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/meta/llama-3-8b-instruct":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/mistralai/mistral-7b-instruct-v0.2":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/mistralai/mistral-7b-v0.1":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.25},"replicate/mistralai/mixtral-8x7b-instruct-v0.1":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.0},"replicate/openai/gpt-4.1":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"replicate/openai/gpt-4.1-mini":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"replicate/openai/gpt-4.1-nano":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.39999999999999997},"replicate/openai/gpt-4o":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"replicate/openai/gpt-4o-mini":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"replicate/openai/gpt-5":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":10.0},"replicate/openai/gpt-5-mini":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":2.0},"replicate/openai/gpt-5-nano":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.39999999999999997},"replicate/openai/gpt-oss-120b":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.72},"replicate/openai/o1":{"cr":1.5,"cw":15.0,"i":15.0,"o":60.0},"replicate/openai/o1-mini":{"cr":0.11,"cw":1.1,"i":1.1,"o":4.4},"replicate/openai/o4-mini":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":4.0},"replicate/qwen/qwen3-235b-a22b-instruct-2507":{"cr":0.0264,"cw":0.26399999999999996,"i":0.26399999999999996,"o":1.06},"replicate/xai/grok-4":{"cr":0.72,"cw":7.199999999999999,"i":7.199999999999999,"o":36.0},"replicateopenai/gpt-oss-20b":{"cr":0.009000000000000001,"cw":0.09,"i":0.09,"o":0.36},"rerank-english-v2.0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"rerank-english-v3.0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"rerank-multilingual-v2.0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"rerank-multilingual-v3.0":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"rerank-v3.5":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-13b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-13b-f":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-70b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-70b-b-f":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-7b":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sagemaker/meta-textgeneration-llama-2-7b-f":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sambanova/DeepSeek-R1":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":7.0},"sambanova/DeepSeek-R1-Distill-Llama-70B":{"cr":0.07,"cw":0.7,"i":0.7,"o":1.4},"sambanova/DeepSeek-V3-0324":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.5},"sambanova/DeepSeek-V3.1":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.5},"sambanova/DeepSeek-V3.2":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":4.5},"sambanova/Llama-4-Maverick-17B-128E-Instruct":{"cr":0.06300000000000001,"cw":0.63,"i":0.63,"o":1.7999999999999998},"sambanova/Llama-4-Scout-17B-16E-Instruct":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.7},"sambanova/Meta-Llama-3.1-405B-Instruct":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":10.0},"sambanova/Meta-Llama-3.1-8B-Instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.19999999999999998},"sambanova/Meta-Llama-3.2-1B-Instruct":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.08},"sambanova/Meta-Llama-3.2-3B-Instruct":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.16},"sambanova/Meta-Llama-3.3-70B-Instruct":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.2},"sambanova/Meta-Llama-Guard-3-8B":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"sambanova/MiniMax-M2.7":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.4},"sambanova/QwQ-32B":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.0},"sambanova/Qwen2-Audio-7B-Instruct":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":100.0},"sambanova/Qwen3-32B":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":0.7999999999999999},"sambanova/gemma-4-31B-it":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":1.15},"sambanova/gpt-oss-120b":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.59},"sample_spec":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"sarvam/sarvam-m":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"scaleway/BAAI/bge-multilingual-gemma2":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"scaleway/google/gemma-3-27b-it":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.5},"scaleway/google/gemma-4-26b-a4b-it":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.5},"scaleway/hcompany/holo2-30b-a3b":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.7},"scaleway/meta/llama-3.3-70b-instruct":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"scaleway/mistralai/devstral-2-123b-instruct-2512":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"scaleway/mistralai/mistral-medium-3.5-128b":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":7.5},"scaleway/mistralai/mistral-small-3.2-24b-instruct-2506":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.35},"scaleway/mistralai/pixtral-12b-2409":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"scaleway/mistralai/voxtral-small-24b-2507":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.35},"scaleway/openai/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"scaleway/qwen/qwen3-235b-a22b-instruct-2507":{"cr":0.07500000000000001,"cw":0.75,"i":0.75,"o":2.25},"scaleway/qwen/qwen3-coder-30b-a3b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.7999999999999999},"scaleway/qwen/qwen3-embedding-8b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"scaleway/qwen/qwen3.5-397b-a17b":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.5999999999999996},"scaleway/qwen/qwen3.6-35b-a3b":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"snowflake/claude-3-5-sonnet":{"cr":0.3,"cw":3.0,"i":3.0,"o":15.0},"snowflake/claude-3-7-sonnet":{"cr":0.3,"cw":3.0,"i":3.0,"o":15.0},"snowflake/claude-4-opus":{"cr":0.5,"cw":5.0,"i":5.0,"o":25.0},"snowflake/claude-4-sonnet":{"cr":0.3,"cw":3.0,"i":3.0,"o":15.0},"snowflake/claude-haiku-4-5":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"snowflake/claude-sonnet-4-5":{"cr":0.3,"cw":3.0,"i":3.0,"o":15.0},"snowflake/claude-sonnet-4-6":{"cr":0.3,"cw":3.0,"i":3.0,"o":15.0},"snowflake/deepseek-r1":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"snowflake/llama3.1-405b":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"snowflake/llama3.1-70b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"snowflake/llama3.1-8b":{"cr":0.024,"cw":0.24,"i":0.24,"o":0.24},"snowflake/llama3.3-70b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"snowflake/llama4-maverick":{"cr":0.024,"cw":0.24,"i":0.24,"o":0.9700000000000001},"snowflake/mistral-large2":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"snowflake/openai-gpt-4.1":{"cr":0.5,"cw":2.0,"i":2.0,"o":8.0},"snowflake/openai-gpt-5":{"cr":0.125,"cw":1.25,"i":1.25,"o":10.0},"snowflake/openai-gpt-5-mini":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"snowflake/openai-gpt-5-nano":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"snowflake/snowflake-arctic-embed-l-v2.0":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.0},"snowflake/snowflake-arctic-embed-m-v2.0":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.0},"snowflake/snowflake-llama-3.3-70b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"tensormesh/MiniMaxAI/MiniMax-M2.5":{"cr":0.0,"cw":0.3,"i":0.3,"o":1.2},"tensormesh/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":{"cr":0.0,"cw":0.44999999999999996,"i":0.44999999999999996,"o":1.7999999999999998},"tensormesh/Qwen/Qwen3.5-397B-A17B-FP8":{"cr":0.0,"cw":0.6,"i":0.6,"o":3.5999999999999996},"tensormesh/Qwen/Qwen3.6-27B-FP8":{"cr":0.0,"cw":0.32,"i":0.32,"o":3.1999999999999997},"tensormesh/deepseek-ai/DeepSeek-V4-Flash":{"cr":0.0,"cw":0.14,"i":0.14,"o":0.28},"tensormesh/google/gemma-4-31B-it":{"cr":0.0,"cw":0.14,"i":0.14,"o":0.56},"tensormesh/lukealonso/GLM-5.1-NVFP4-MTP":{"cr":0.0,"cw":1.4,"i":1.4,"o":4.4},"tensormesh/moonshotai/Kimi-K2.6":{"cr":0.0,"cw":0.96,"i":0.96,"o":4.0},"tensormesh/openai/gpt-oss-120b":{"cr":0.0,"cw":0.15,"i":0.15,"o":0.6},"tensormesh/openai/gpt-oss-20b":{"cr":0.0,"cw":0.07,"i":0.07,"o":0.28},"text-completion-codestral/codestral-2405":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"text-completion-codestral/codestral-latest":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"text-completion-inception/mercury-edit-2":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.75},"text-embedding-004":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-embedding-005":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-embedding-3-large":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.0},"text-embedding-3-small":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"text-embedding-ada-002":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-embedding-ada-002-v2":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-embedding-large-exp-03-07":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-embedding-preview-0409":{"cr":0.000625,"cw":0.0062499999999999995,"i":0.0062499999999999995,"o":0.0},"text-moderation-007":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"text-moderation-latest":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"text-moderation-stable":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"text-multilingual-embedding-002":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"text-unicorn":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":28.0},"text-unicorn@001":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":28.0},"together-ai-21.1b-41b":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":0.7999999999999999},"together-ai-4.1b-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"together-ai-41.1b-80b":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"together-ai-8.1b-21b":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.3},"together-ai-81.1b-110b":{"cr":0.18,"cw":1.7999999999999998,"i":1.7999999999999998,"o":1.7999999999999998},"together-ai-embedding-151m-to-350m":{"cr":0.0016000000000000003,"cw":0.016,"i":0.016,"o":0.0},"together-ai-embedding-up-to-150m":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"together-ai-up-to-4b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"together_ai/BAAI/bge-base-en-v1.5":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":6.0},"together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507":{"cr":0.065,"cw":0.65,"i":0.65,"o":3.0},"together_ai/Qwen/Qwen3-235B-A22B-fp8-tput":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.5},"together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.5},"together_ai/Qwen/Qwen3.5-397B-A17B":{"cr":0.06,"cw":0.6,"i":0.6,"o":3.5999999999999996},"together_ai/baai/bge-base-en-v1.5":{"cr":0.0008000000000000001,"cw":0.008,"i":0.008,"o":0.0},"together_ai/deepseek-ai/DeepSeek-R1":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":7.0},"together_ai/deepseek-ai/DeepSeek-R1-0528-tput":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"together_ai/deepseek-ai/DeepSeek-V3":{"cr":0.12500000000000003,"cw":1.25,"i":1.25,"o":1.25},"together_ai/deepseek-ai/DeepSeek-V3.1":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.7},"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo":{"cr":0.08800000000000001,"cw":0.88,"i":0.88,"o":0.88},"together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"cr":0.027000000000000003,"cw":0.27,"i":0.27,"o":0.85},"together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.59},"together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo":{"cr":0.35000000000000003,"cw":3.5,"i":3.5,"o":3.5},"together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo":{"cr":0.08800000000000001,"cw":0.88,"i":0.88,"o":0.88},"together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.18},"together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"together_ai/moonshotai/Kimi-K2-Instruct":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"together_ai/moonshotai/Kimi-K2-Instruct-0905":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"together_ai/moonshotai/Kimi-K2.5":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":2.8},"together_ai/openai/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"together_ai/openai/gpt-oss-20b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.19999999999999998},"together_ai/zai-org/GLM-4.5-Air-FP8":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.1},"together_ai/zai-org/GLM-4.6":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"together_ai/zai-org/GLM-4.7":{"cr":0.045,"cw":0.44999999999999996,"i":0.44999999999999996,"o":2.0},"twelvelabs.marengo-embed-2-7-v1:0":{"cr":7.0,"cw":70.0,"i":70.0,"o":0.0},"us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.36,"cra":0.72,"cw":4.5,"cwa":9.0,"i":3.5999999999999996,"ia":7.199999999999999,"o":18.0,"oa":27.0},"us.amazon.nova-2-lite-v1:0":{"cr":0.0825,"cw":0.33,"i":0.33,"o":2.75},"us.amazon.nova-2-pro-preview-20251202-v1:0":{"cr":0.546875,"cw":2.1875,"i":2.1875,"o":17.5},"us.amazon.nova-lite-v1:0":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"us.amazon.nova-micro-v1:0":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.14},"us.amazon.nova-premier-v1:0":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":12.5},"us.amazon.nova-pro-v1:0":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":3.1999999999999997},"us.anthropic.claude-3-5-haiku-20241022-v1:0":{"cr":0.08,"cw":1.0,"i":0.7999999999999999,"o":4.0},"us.anthropic.claude-3-5-sonnet-20240620-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"us.anthropic.claude-3-5-sonnet-20241022-v2:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"us.anthropic.claude-3-7-sonnet-20250219-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"us.anthropic.claude-3-haiku-20240307-v1:0":{"cr":0.024999999999999998,"cw":0.3125,"i":0.25,"o":1.25},"us.anthropic.claude-3-opus-20240229-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"us.anthropic.claude-3-sonnet-20240229-v1:0":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"us.anthropic.claude-fable-5":{"cr":1.1,"cw":13.75,"i":11.0,"o":55.0},"us.anthropic.claude-haiku-4-5-20251001-v1:0":{"cr":0.11,"cw":1.375,"i":1.1,"o":5.5},"us.anthropic.claude-opus-4-1-20250805-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"us.anthropic.claude-opus-4-20250514-v1:0":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"us.anthropic.claude-opus-4-5-20251101-v1:0":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"us.anthropic.claude-opus-4-6-v1":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"us.anthropic.claude-opus-4-7":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"us.anthropic.claude-opus-4-8":{"cr":0.55,"cw":6.875,"i":5.5,"o":27.5},"us.anthropic.claude-sonnet-4-20250514-v1:0":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"us.anthropic.claude-sonnet-4-5-20250929-v1:0":{"cr":0.33,"cra":0.66,"cw":4.125,"cwa":8.25,"i":3.3000000000000003,"ia":6.6000000000000005,"o":16.5,"oa":24.75},"us.anthropic.claude-sonnet-4-6":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"us.anthropic.claude-sonnet-5":{"cr":0.33,"cw":4.125,"i":3.3000000000000003,"o":16.5},"us.deepseek.r1-v1:0":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"us.deepseek.v3.2":{"cr":0.062,"cw":0.62,"i":0.62,"o":1.85},"us.meta.llama3-1-405b-instruct-v1:0":{"cr":0.532,"cw":5.32,"i":5.32,"o":16.0},"us.meta.llama3-1-70b-instruct-v1:0":{"cr":0.09900000000000002,"cw":0.9900000000000001,"i":0.9900000000000001,"o":0.9900000000000001},"us.meta.llama3-1-8b-instruct-v1:0":{"cr":0.022000000000000002,"cw":0.22,"i":0.22,"o":0.22},"us.meta.llama3-2-11b-instruct-v1:0":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.35},"us.meta.llama3-2-1b-instruct-v1:0":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"us.meta.llama3-2-3b-instruct-v1:0":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"us.meta.llama3-2-90b-instruct-v1:0":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"us.meta.llama3-3-70b-instruct-v1:0":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"us.meta.llama4-maverick-17b-instruct-v1:0":{"cr":0.024,"cw":0.24,"i":0.24,"o":0.9700000000000001},"us.meta.llama4-scout-17b-instruct-v1:0":{"cr":0.017,"cw":0.16999999999999998,"i":0.16999999999999998,"o":0.66},"us.mistral.pixtral-large-2502-v1:0":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"us.twelvelabs.marengo-embed-2-7-v1:0":{"cr":7.0,"cw":70.0,"i":70.0,"o":0.0},"us.writer.palmyra-x4-v1:0":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"us.writer.palmyra-x5-v1:0":{"cr":0.06,"cw":0.6,"i":0.6,"o":6.0},"v0/v0-1.0-md":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"v0/v0-1.5-lg":{"cr":1.5,"cw":15.0,"i":15.0,"o":75.0},"v0/v0-1.5-md":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/alibaba/qwen-3-14b":{"cr":0.008,"cw":0.08,"i":0.08,"o":0.24},"vercel_ai_gateway/alibaba/qwen-3-235b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"vercel_ai_gateway/alibaba/qwen-3-30b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"vercel_ai_gateway/alibaba/qwen-3-32b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"vercel_ai_gateway/alibaba/qwen3-coder":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":1.5999999999999999},"vercel_ai_gateway/amazon/nova-lite":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.24},"vercel_ai_gateway/amazon/nova-micro":{"cr":0.0035000000000000005,"cw":0.035,"i":0.035,"o":0.14},"vercel_ai_gateway/amazon/nova-pro":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":3.1999999999999997},"vercel_ai_gateway/amazon/titan-embed-text-v2":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"vercel_ai_gateway/anthropic/claude-3-5-sonnet":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-3-5-sonnet-20241022":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-3-7-sonnet":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-3-haiku":{"cr":0.03,"cw":0.3,"i":0.25,"o":1.25},"vercel_ai_gateway/anthropic/claude-3-opus":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vercel_ai_gateway/anthropic/claude-3.5-haiku":{"cr":0.08,"cw":1.0,"i":0.7999999999999999,"o":4.0},"vercel_ai_gateway/anthropic/claude-3.5-sonnet":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-3.7-sonnet":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-4-opus":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vercel_ai_gateway/anthropic/claude-4-sonnet":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-haiku-4.5":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"vercel_ai_gateway/anthropic/claude-opus-4":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vercel_ai_gateway/anthropic/claude-opus-4.1":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vercel_ai_gateway/anthropic/claude-opus-4.5":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vercel_ai_gateway/anthropic/claude-opus-4.6":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vercel_ai_gateway/anthropic/claude-sonnet-4":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/anthropic/claude-sonnet-4.5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vercel_ai_gateway/cohere/command-a":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"vercel_ai_gateway/cohere/command-r":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"vercel_ai_gateway/cohere/command-r-plus":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"vercel_ai_gateway/cohere/embed-v4.0":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"vercel_ai_gateway/deepseek/deepseek-r1":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.1900000000000004},"vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b":{"cr":0.07500000000000001,"cw":0.75,"i":0.75,"o":0.9900000000000001},"vercel_ai_gateway/deepseek/deepseek-v3":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":0.8999999999999999},"vercel_ai_gateway/google/gemini-2.0-flash":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"vercel_ai_gateway/google/gemini-2.0-flash-lite":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"vercel_ai_gateway/google/gemini-2.5-flash":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"vercel_ai_gateway/google/gemini-2.5-pro":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"vercel_ai_gateway/google/gemini-embedding-001":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.0},"vercel_ai_gateway/google/gemma-2-9b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"vercel_ai_gateway/google/text-embedding-005":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.0},"vercel_ai_gateway/google/text-multilingual-embedding-002":{"cr":0.0025,"cw":0.024999999999999998,"i":0.024999999999999998,"o":0.0},"vercel_ai_gateway/inception/mercury-coder-small":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.0},"vercel_ai_gateway/meta/llama-3-70b":{"cr":0.059,"cw":0.59,"i":0.59,"o":0.7899999999999999},"vercel_ai_gateway/meta/llama-3-8b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.08},"vercel_ai_gateway/meta/llama-3.1-70b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"vercel_ai_gateway/meta/llama-3.1-8b":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.08},"vercel_ai_gateway/meta/llama-3.2-11b":{"cr":0.016,"cw":0.16,"i":0.16,"o":0.16},"vercel_ai_gateway/meta/llama-3.2-1b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"vercel_ai_gateway/meta/llama-3.2-3b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"vercel_ai_gateway/meta/llama-3.2-90b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"vercel_ai_gateway/meta/llama-3.3-70b":{"cr":0.07200000000000001,"cw":0.72,"i":0.72,"o":0.72},"vercel_ai_gateway/meta/llama-4-maverick":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"vercel_ai_gateway/meta/llama-4-scout":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"vercel_ai_gateway/mistral/codestral":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"vercel_ai_gateway/mistral/codestral-embed":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.0},"vercel_ai_gateway/mistral/devstral-small":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.28},"vercel_ai_gateway/mistral/magistral-medium":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":5.0},"vercel_ai_gateway/mistral/magistral-small":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"vercel_ai_gateway/mistral/ministral-3b":{"cr":0.004,"cw":0.04,"i":0.04,"o":0.04},"vercel_ai_gateway/mistral/ministral-8b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"vercel_ai_gateway/mistral/mistral-embed":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"vercel_ai_gateway/mistral/mistral-large":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vercel_ai_gateway/mistral/mistral-saba-24b":{"cr":0.079,"cw":0.7899999999999999,"i":0.7899999999999999,"o":0.7899999999999999},"vercel_ai_gateway/mistral/mistral-small":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"vercel_ai_gateway/mistral/mixtral-8x22b-instruct":{"cr":0.12,"cw":1.2,"i":1.2,"o":1.2},"vercel_ai_gateway/mistral/pixtral-12b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"vercel_ai_gateway/mistral/pixtral-large":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vercel_ai_gateway/moonshotai/kimi-k2":{"cr":0.055,"cw":0.55,"i":0.55,"o":2.2},"vercel_ai_gateway/morph/morph-v3-fast":{"cr":0.08,"cw":0.7999999999999999,"i":0.7999999999999999,"o":1.2},"vercel_ai_gateway/morph/morph-v3-large":{"cr":0.09,"cw":0.8999999999999999,"i":0.8999999999999999,"o":1.9},"vercel_ai_gateway/openai/gpt-3.5-turbo":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":1.5},"vercel_ai_gateway/openai/gpt-3.5-turbo-instruct":{"cr":0.15000000000000002,"cw":1.5,"i":1.5,"o":2.0},"vercel_ai_gateway/openai/gpt-4-turbo":{"cr":1.0000000000000002,"cw":10.0,"i":10.0,"o":30.0},"vercel_ai_gateway/openai/gpt-4.1":{"cr":0.5,"cw":0.0,"i":2.0,"o":8.0},"vercel_ai_gateway/openai/gpt-4.1-mini":{"cr":0.09999999999999999,"cw":0.0,"i":0.39999999999999997,"o":1.5999999999999999},"vercel_ai_gateway/openai/gpt-4.1-nano":{"cr":0.024999999999999998,"cw":0.0,"i":0.09999999999999999,"o":0.39999999999999997},"vercel_ai_gateway/openai/gpt-4o":{"cr":1.25,"cw":0.0,"i":2.5,"o":10.0},"vercel_ai_gateway/openai/gpt-4o-mini":{"cr":0.075,"cw":0.0,"i":0.15,"o":0.6},"vercel_ai_gateway/openai/o1":{"cr":7.5,"cw":0.0,"i":15.0,"o":60.0},"vercel_ai_gateway/openai/o3":{"cr":0.5,"cw":0.0,"i":2.0,"o":8.0},"vercel_ai_gateway/openai/o3-mini":{"cr":0.55,"cw":0.0,"i":1.1,"o":4.4},"vercel_ai_gateway/openai/o4-mini":{"cr":0.275,"cw":0.0,"i":1.1,"o":4.4},"vercel_ai_gateway/openai/text-embedding-3-large":{"cr":0.013000000000000001,"cw":0.13,"i":0.13,"o":0.0},"vercel_ai_gateway/openai/text-embedding-3-small":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"vercel_ai_gateway/openai/text-embedding-ada-002":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"vercel_ai_gateway/perplexity/sonar":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":1.0},"vercel_ai_gateway/perplexity/sonar-pro":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/perplexity/sonar-reasoning":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"vercel_ai_gateway/perplexity/sonar-reasoning-pro":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"vercel_ai_gateway/vercel/v0-1.0-md":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/vercel/v0-1.5-md":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/xai/grok-2":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"vercel_ai_gateway/xai/grok-2-vision":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"vercel_ai_gateway/xai/grok-3":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/xai/grok-3-fast":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":25.0},"vercel_ai_gateway/xai/grok-3-mini":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.5},"vercel_ai_gateway/xai/grok-3-mini-fast":{"cr":0.06,"cw":0.6,"i":0.6,"o":4.0},"vercel_ai_gateway/xai/grok-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vercel_ai_gateway/zai/glm-4.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"vercel_ai_gateway/zai/glm-4.5-air":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.1},"vercel_ai_gateway/zai/glm-4.6":{"cr":0.11,"cw":0.44999999999999996,"i":0.44999999999999996,"o":1.7999999999999998},"vertex_ai/claude-3-5-haiku":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"vertex_ai/claude-3-5-haiku@20241022":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":5.0},"vertex_ai/claude-3-5-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vertex_ai/claude-3-5-sonnet@20240620":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vertex_ai/claude-3-7-sonnet@20250219":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vertex_ai/claude-3-haiku":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.25},"vertex_ai/claude-3-haiku@20240307":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.25},"vertex_ai/claude-3-opus":{"cr":1.5,"cw":15.0,"i":15.0,"o":75.0},"vertex_ai/claude-3-opus@20240229":{"cr":1.5,"cw":15.0,"i":15.0,"o":75.0},"vertex_ai/claude-3-sonnet":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vertex_ai/claude-3-sonnet@20240229":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"vertex_ai/claude-fable-5":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"vertex_ai/claude-fable-5@default":{"cr":1.0,"cw":12.5,"i":10.0,"o":50.0},"vertex_ai/claude-haiku-4-5":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"vertex_ai/claude-haiku-4-5@20251001":{"cr":0.09999999999999999,"cw":1.25,"i":1.0,"o":5.0},"vertex_ai/claude-opus-4":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vertex_ai/claude-opus-4-1":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vertex_ai/claude-opus-4-1@20250805":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vertex_ai/claude-opus-4-5":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-5@20251101":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-6":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-6@default":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-7":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-7@default":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-8":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4-8@default":{"cr":0.5,"cw":6.25,"i":5.0,"o":25.0},"vertex_ai/claude-opus-4@20250514":{"cr":1.5,"cw":18.75,"i":15.0,"o":75.0},"vertex_ai/claude-sonnet-4":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"vertex_ai/claude-sonnet-4-5":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"vertex_ai/claude-sonnet-4-5@20250929":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"vertex_ai/claude-sonnet-4-6":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vertex_ai/claude-sonnet-4-6@default":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vertex_ai/claude-sonnet-4@20250514":{"cr":0.3,"cra":0.6,"cw":3.75,"cwa":7.5,"i":3.0,"ia":6.0,"o":15.0,"oa":22.5},"vertex_ai/claude-sonnet-5":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vertex_ai/claude-sonnet-5@default":{"cr":0.3,"cw":3.75,"i":3.0,"o":15.0},"vertex_ai/codestral-2":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"vertex_ai/codestral-2501":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"vertex_ai/codestral-2@001":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"vertex_ai/codestral@2405":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"vertex_ai/codestral@latest":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.6},"vertex_ai/deep-research-pro-preview-12-2025":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"vertex_ai/deepseek-ai/deepseek-ocr-maas":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"vertex_ai/deepseek-ai/deepseek-r1-0528-maas":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"vertex_ai/deepseek-ai/deepseek-v3.1-maas":{"cr":0.135,"cw":1.35,"i":1.35,"o":5.4},"vertex_ai/deepseek-ai/deepseek-v3.2-maas":{"cr":0.05600000000000001,"cw":0.56,"i":0.56,"o":1.68},"vertex_ai/gemini-2.5-flash-image":{"cr":0.03,"cw":0.3,"i":0.3,"o":2.5},"vertex_ai/gemini-3-flash-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"vertex_ai/gemini-3-pro-image":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"vertex_ai/gemini-3-pro-image-preview":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":12.0},"vertex_ai/gemini-3-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"vertex_ai/gemini-3.1-flash-image":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"vertex_ai/gemini-3.1-flash-image-preview":{"cr":0.049999999999999996,"cw":0.5,"i":0.5,"o":3.0},"vertex_ai/gemini-3.1-flash-lite":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"vertex_ai/gemini-3.1-flash-lite-preview":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.5},"vertex_ai/gemini-3.1-pro-preview":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"vertex_ai/gemini-3.1-pro-preview-customtools":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":2.0,"cwa":0.25,"i":2.0,"ia":4.0,"o":12.0,"oa":18.0},"vertex_ai/gemini-3.5-flash":{"cr":0.15,"cw":1.5,"i":1.5,"o":9.0},"vertex_ai/gemini-embedding-2":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"vertex_ai/gemini-embedding-2-preview":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.0},"vertex_ai/google/gemma-4-26b-a4b-it-maas":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"vertex_ai/jamba-1.5":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"vertex_ai/jamba-1.5-large":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"vertex_ai/jamba-1.5-large@001":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":8.0},"vertex_ai/jamba-1.5-mini":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"vertex_ai/jamba-1.5-mini@001":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.39999999999999997},"vertex_ai/meta/llama-3.1-405b-instruct-maas":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":16.0},"vertex_ai/meta/llama-3.1-70b-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/meta/llama-3.1-8b-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/meta/llama-3.2-90b-vision-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.15},"vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.15},"vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.7},"vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":0.7},"vertex_ai/meta/llama3-405b-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/meta/llama3-70b-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/meta/llama3-8b-instruct-maas":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"vertex_ai/minimaxai/minimax-m2-maas":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"vertex_ai/mistral-large-2411":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/mistral-large@2407":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/mistral-large@2411-001":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/mistral-large@latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/mistral-medium-3":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"vertex_ai/mistral-medium-3@001":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"vertex_ai/mistral-nemo@2407":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":3.0},"vertex_ai/mistral-nemo@latest":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"vertex_ai/mistral-small-2503":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"vertex_ai/mistral-small-2503@001":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.0},"vertex_ai/mistralai/codestral-2":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"vertex_ai/mistralai/codestral-2@001":{"cr":0.03,"cw":0.3,"i":0.3,"o":0.8999999999999999},"vertex_ai/mistralai/mistral-medium-3":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"vertex_ai/mistralai/mistral-medium-3@001":{"cr":0.04,"cw":0.39999999999999997,"i":0.39999999999999997,"o":2.0},"vertex_ai/moonshotai/kimi-k2-thinking-maas":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"vertex_ai/openai/gpt-oss-120b-maas":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"vertex_ai/openai/gpt-oss-20b-maas":{"cr":0.0075,"cw":0.075,"i":0.075,"o":0.3},"vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas":{"cr":0.024999999999999998,"cw":0.25,"i":0.25,"o":1.0},"vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":4.0},"vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.2},"vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas":{"cr":0.015,"cw":0.15,"i":0.15,"o":1.2},"vertex_ai/xai/grok-4.1-fast-non-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"vertex_ai/xai/grok-4.1-fast-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"vertex_ai/xai/grok-4.20-non-reasoning":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/xai/grok-4.20-reasoning":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"vertex_ai/zai-org/glm-4.7-maas":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"vertex_ai/zai-org/glm-5-maas":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.1999999999999997},"voyage/rerank-2":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.0},"voyage/rerank-2-lite":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"voyage/rerank-2.5":{"cr":0.005,"cw":0.049999999999999996,"i":0.049999999999999996,"o":0.0},"voyage/rerank-2.5-lite":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"voyage/voyage-2":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"voyage/voyage-3":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.0},"voyage/voyage-3-large":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.0},"voyage/voyage-3-lite":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"voyage/voyage-3.5":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.0},"voyage/voyage-3.5-lite":{"cr":0.002,"cw":0.02,"i":0.02,"o":0.0},"voyage/voyage-code-2":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"voyage/voyage-code-3":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.0},"voyage/voyage-context-3":{"cr":0.018000000000000002,"cw":0.18,"i":0.18,"o":0.0},"voyage/voyage-finance-2":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"voyage/voyage-large-2":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"voyage/voyage-law-2":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"voyage/voyage-lite-01":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"voyage/voyage-lite-02-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.0},"voyage/voyage-multimodal-3":{"cr":0.012,"cw":0.12,"i":0.12,"o":0.0},"wandb/MiniMaxAI/MiniMax-M2.5":{"cr":0.03,"cw":0.3,"i":0.3,"o":1.2},"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507":{"cr":1000.0,"cw":10000.0,"i":10000.0,"o":10000.0},"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507":{"cr":1000.0,"cw":10000.0,"i":10000.0,"o":10000.0},"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct":{"cr":10000.000000000002,"cw":100000.0,"i":100000.0,"o":150000.0},"wandb/deepseek-ai/DeepSeek-R1-0528":{"cr":13500.000000000002,"cw":135000.0,"i":135000.0,"o":540000.0},"wandb/deepseek-ai/DeepSeek-V3-0324":{"cr":11400.0,"cw":114000.0,"i":114000.0,"o":275000.0},"wandb/deepseek-ai/DeepSeek-V3.1":{"cr":5500.000000000001,"cw":55000.0,"i":55000.0,"o":165000.0},"wandb/meta-llama/Llama-3.1-8B-Instruct":{"cr":2200.0,"cw":22000.0,"i":22000.0,"o":22000.0},"wandb/meta-llama/Llama-3.3-70B-Instruct":{"cr":7099.999999999999,"cw":71000.0,"i":71000.0,"o":71000.0},"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct":{"cr":1700.0000000000002,"cw":17000.0,"i":17000.0,"o":66000.0},"wandb/microsoft/Phi-4-mini-instruct":{"cr":800.0,"cw":8000.0,"i":8000.0,"o":35000.0},"wandb/moonshotai/Kimi-K2-Instruct":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.5},"wandb/moonshotai/Kimi-K2.5":{"cr":0.09999999999999999,"cw":0.6,"i":0.6,"o":3.0},"wandb/openai/gpt-oss-120b":{"cr":1500.0,"cw":15000.0,"i":15000.0,"o":60000.0},"wandb/openai/gpt-oss-20b":{"cr":500.0,"cw":5000.0,"i":5000.0,"o":20000.0},"wandb/zai-org/GLM-4.5":{"cr":5500.000000000001,"cw":55000.0,"i":55000.0,"o":200000.0},"watsonx/bigscience/mt0-xxl-13b":{"cr":50.0,"cw":500.0,"i":500.0,"o":2000.0},"watsonx/core42/jais-13b-chat":{"cr":50.0,"cw":500.0,"i":500.0,"o":2000.0},"watsonx/google/flan-t5-xl-3b":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"watsonx/ibm/granite-13b-chat-v2":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"watsonx/ibm/granite-13b-instruct-v2":{"cr":0.06,"cw":0.6,"i":0.6,"o":0.6},"watsonx/ibm/granite-3-3-8b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"watsonx/ibm/granite-3-8b-instruct":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"watsonx/ibm/granite-4-h-small":{"cr":0.006,"cw":0.06,"i":0.06,"o":0.25},"watsonx/ibm/granite-guardian-3-2-2b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"watsonx/ibm/granite-guardian-3-3-8b":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.19999999999999998},"watsonx/ibm/granite-ttm-1024-96-r2":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":0.38},"watsonx/ibm/granite-ttm-1536-96-r2":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":0.38},"watsonx/ibm/granite-ttm-512-96-r2":{"cr":0.038000000000000006,"cw":0.38,"i":0.38,"o":0.38},"watsonx/ibm/granite-vision-3-2-2b":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"watsonx/meta-llama/llama-3-2-11b-vision-instruct":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.35},"watsonx/meta-llama/llama-3-2-1b-instruct":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"watsonx/meta-llama/llama-3-2-3b-instruct":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.15},"watsonx/meta-llama/llama-3-2-90b-vision-instruct":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":2.0},"watsonx/meta-llama/llama-3-3-70b-instruct":{"cr":0.071,"cw":0.71,"i":0.71,"o":0.71},"watsonx/meta-llama/llama-4-maverick-17b":{"cr":0.035,"cw":0.35,"i":0.35,"o":1.4},"watsonx/meta-llama/llama-guard-3-11b-vision":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.35},"watsonx/mistralai/mistral-large":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":10.0},"watsonx/mistralai/mistral-medium-2505":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":10.0},"watsonx/mistralai/mistral-small-2503":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"watsonx/mistralai/mistral-small-3-1-24b-instruct-2503":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.3},"watsonx/mistralai/pixtral-12b-2409":{"cr":0.035,"cw":0.35,"i":0.35,"o":0.35},"watsonx/openai/gpt-oss-120b":{"cr":0.015,"cw":0.15,"i":0.15,"o":0.6},"watsonx/sdaia/allam-1-13b-instruct":{"cr":0.18,"cw":1.7999999999999998,"i":1.7999999999999998,"o":1.7999999999999998},"writer.palmyra-x4-v1:0":{"cr":0.25000000000000006,"cw":2.5,"i":2.5,"o":10.0},"writer.palmyra-x5-v1:0":{"cr":0.06,"cw":0.6,"i":0.6,"o":6.0},"xai/grok-2":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-2-1212":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-2-latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-2-vision":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-2-vision-1212":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-2-vision-latest":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":10.0},"xai/grok-3":{"cr":0.75,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-3-beta":{"cr":0.75,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-3-fast-beta":{"cr":1.25,"cw":5.0,"i":5.0,"o":25.0},"xai/grok-3-fast-latest":{"cr":1.25,"cw":5.0,"i":5.0,"o":25.0},"xai/grok-3-latest":{"cr":0.75,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-3-mini":{"cr":0.075,"cw":0.3,"i":0.3,"o":0.5},"xai/grok-3-mini-beta":{"cr":0.075,"cw":0.3,"i":0.3,"o":0.5},"xai/grok-3-mini-fast":{"cr":0.15,"cw":0.6,"i":0.6,"o":4.0},"xai/grok-3-mini-fast-beta":{"cr":0.15,"cw":0.6,"i":0.6,"o":4.0},"xai/grok-3-mini-fast-latest":{"cr":0.15,"cw":0.6,"i":0.6,"o":4.0},"xai/grok-3-mini-latest":{"cr":0.075,"cw":0.3,"i":0.3,"o":0.5},"xai/grok-4":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-4-0709":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-4-1-fast":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-1-fast-non-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-1-fast-non-reasoning-latest":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-1-fast-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-1-fast-reasoning-latest":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-fast-non-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-fast-reasoning":{"cr":0.049999999999999996,"cw":0.19999999999999998,"i":0.19999999999999998,"o":0.5},"xai/grok-4-latest":{"cr":0.30000000000000004,"cw":3.0,"i":3.0,"o":15.0},"xai/grok-4.20-0309-reasoning":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"xai/grok-4.20-beta-0309-non-reasoning":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"xai/grok-4.20-beta-0309-reasoning":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"xai/grok-4.20-multi-agent-beta-0309":{"cr":0.19999999999999998,"cw":2.0,"i":2.0,"o":6.0},"xai/grok-4.3":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":1.25,"i":1.25,"ia":2.5,"o":2.5,"oa":5.0},"xai/grok-4.3-latest":{"cr":0.19999999999999998,"cra":0.39999999999999997,"cw":1.25,"i":1.25,"ia":2.5,"o":2.5,"oa":5.0},"xai/grok-beta":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"xai/grok-code-fast":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.5},"xai/grok-code-fast-1":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.5},"xai/grok-code-fast-1-0825":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.5},"xai/grok-vision-beta":{"cr":0.5000000000000001,"cw":5.0,"i":5.0,"o":15.0},"zai.glm-4.7":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"zai.glm-4.7-flash":{"cr":0.007000000000000001,"cw":0.07,"i":0.07,"o":0.39999999999999997},"zai.glm-5":{"cr":0.09999999999999999,"cw":1.0,"i":1.0,"o":3.1999999999999997},"zai/glm-4-32b-0414-128k":{"cr":0.01,"cw":0.09999999999999999,"i":0.09999999999999999,"o":0.09999999999999999},"zai/glm-4.5":{"cr":0.06,"cw":0.6,"i":0.6,"o":2.2},"zai/glm-4.5-air":{"cr":0.02,"cw":0.19999999999999998,"i":0.19999999999999998,"o":1.1},"zai/glm-4.5-airx":{"cr":0.11,"cw":1.1,"i":1.1,"o":4.5},"zai/glm-4.5-flash":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"zai/glm-4.5-x":{"cr":0.22,"cw":2.2,"i":2.2,"o":8.9},"zai/glm-4.5v":{"cr":0.06,"cw":0.6,"i":0.6,"o":1.7999999999999998},"zai/glm-4.6":{"cr":0.11,"cw":0.0,"i":0.6,"o":2.2},"zai/glm-4.7":{"cr":0.11,"cw":0.0,"i":0.6,"o":2.2},"zai/glm-4.7-flash":{"cr":0.0,"cw":0.0,"i":0.0,"o":0.0},"zai/glm-5":{"cr":0.19999999999999998,"cw":0.0,"i":1.0,"o":3.1999999999999997},"zai/glm-5-code":{"cr":0.3,"cw":0.0,"i":1.2,"o":5.0},"zai/glm-5.1":{"cr":0.26,"cw":0.0,"i":1.4,"o":4.4}},"retrieved_at":"2026-07-02T12:43:06Z"} \ No newline at end of file diff --git a/crates/openusage-core/data/pricing_supplement.json b/crates/openusage-core/data/pricing_supplement.json new file mode 100644 index 000000000..a383316e6 --- /dev/null +++ b/crates/openusage-core/data/pricing_supplement.json @@ -0,0 +1,151 @@ +{ + "$comment": "OpenUsage pricing supplement. Prices Cursor-native models that no public catalog carries, supplies fast-variant multipliers the catalogs omit, and maps provider log/CSV model slugs to canonical pricing keys (LiteLLM keys, models.dev ids, or entries in this file). Published to gh-pages by .github/workflows/pricing-supplement.yml on every change, so installed apps pick edits up within a day - no release needed. Rates are USD per million tokens. Sources: https://cursor.com/docs/models-and-pricing.md for Cursor-native entries.", + "updated_at": "2026-07-03", + "pricing": { + "auto": { + "input_per_million": 1.25, + "cache_write_per_million": 1.25, + "cache_read_per_million": 0.25, + "output_per_million": 6.0 + }, + "composer-1": { + "input_per_million": 1.25, + "cache_write_per_million": 1.25, + "cache_read_per_million": 0.125, + "output_per_million": 10.0 + }, + "composer-1.5": { + "input_per_million": 3.5, + "cache_write_per_million": 3.5, + "cache_read_per_million": 0.35, + "output_per_million": 17.5 + }, + "composer-2": { + "input_per_million": 0.5, + "cache_write_per_million": 0.5, + "cache_read_per_million": 0.2, + "output_per_million": 2.5 + }, + "composer-2-fast": { + "input_per_million": 1.5, + "cache_write_per_million": 1.5, + "cache_read_per_million": 0.35, + "output_per_million": 7.5 + }, + "composer-2.5": { + "input_per_million": 0.5, + "cache_write_per_million": 0.5, + "cache_read_per_million": 0.2, + "output_per_million": 2.5 + }, + "composer-2.5-fast": { + "input_per_million": 3.0, + "cache_write_per_million": 3.0, + "cache_read_per_million": 0.5, + "output_per_million": 15.0 + }, + "github_bugbot": { + "input_per_million": 5.0, + "cache_write_per_million": 5.0, + "cache_read_per_million": 0.5, + "output_per_million": 30.0 + }, + "claude-opus-4-7-fast": { + "$comment": "Cursor bills Opus 4.7 fast mode at these rates; the models.dev entry carries higher (stale) numbers, so this override wins.", + "input_per_million": 30.0, + "cache_write_per_million": 37.5, + "cache_read_per_million": 3.0, + "output_per_million": 150.0 + }, + "claude-opus-4-8-fast": { + "$comment": "Cursor states Opus 4.8 fast mode is 3x lower per-token than Opus 4.7 fast mode (= 2x the Opus 4.8 base rate, matching LiteLLM's fast multiplier); the models.dev entry disagrees, so this override wins.", + "input_per_million": 10.0, + "cache_write_per_million": 12.5, + "cache_read_per_million": 1.0, + "output_per_million": 50.0 + }, + "claude-4-sonnet-1m": { + "$comment": "Cursor's long-context Sonnet 4 tier: the whole request bills at the >200k rate.", + "input_per_million": 6.0, + "cache_write_per_million": 7.5, + "cache_read_per_million": 0.6, + "output_per_million": 22.5 + } + }, + "fast_multipliers": { + "gpt-5": 2.0, + "gpt-5.1-codex": 2.0, + "gpt-5.1-codex-max": 2.0, + "gpt-5.2": 2.0, + "gpt-5.2-codex": 2.0, + "gpt-5.3-codex": 2.0, + "gpt-5.4": 2.0, + "gpt-5.5": 2.5 + }, + "alias_rules": [ + { "pattern": "^agent_review$", "canonical": "gpt-5.4" }, + { "pattern": "^default$", "canonical": "auto" }, + { "pattern": "^auto$", "canonical": "auto" }, + { "pattern": "^composer-1$", "canonical": "composer-1" }, + { "pattern": "^composer-1\\.5$", "canonical": "composer-1.5" }, + { "pattern": "^composer-2$", "canonical": "composer-2" }, + { "pattern": "^composer-2-fast$", "canonical": "composer-2-fast" }, + { "pattern": "^composer-2\\.5$", "canonical": "composer-2.5" }, + { "pattern": "^composer-2\\.5-fast$", "canonical": "composer-2.5-fast" }, + { "pattern": "^github_bugbot$", "canonical": "github_bugbot" }, + { "pattern": "^claude-4\\.5-haiku(?:-thinking)?$", "canonical": "claude-haiku-4-5" }, + { "pattern": "^claude-4\\.5-opus-(?:low|medium|high)(?:-thinking)?$", "canonical": "claude-opus-4-5" }, + { "pattern": "^claude-4-sonnet-1m(?:-thinking)?$", "canonical": "claude-4-sonnet-1m" }, + { "pattern": "^claude-4-sonnet(?:-thinking)?$", "canonical": "claude-sonnet-4-20250514" }, + { "pattern": "^claude-4\\.5-sonnet(?:-thinking)?$", "canonical": "claude-sonnet-4-5" }, + { "pattern": "^claude-4\\.6-sonnet(?:-(?:low|medium|high|xhigh|max))?(?:-thinking)?$", "canonical": "claude-sonnet-4-6" }, + { "pattern": "^claude-4\\.6-opus-(?:low|medium|high|max)(?:-thinking)?-fast$", "canonical": "claude-opus-4-6-fast" }, + { "pattern": "^claude-4\\.6-opus-(?:low|medium|high|max)(?:-thinking)?$", "canonical": "claude-opus-4-6" }, + { "pattern": "^claude-opus-4-7(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?-fast$", "canonical": "claude-opus-4-7-fast" }, + { "pattern": "^claude-opus-4-7(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-4-7" }, + { "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?-fast$", "canonical": "claude-opus-4-8-fast" }, + { "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-4-8" }, + { "pattern": "^claude-fable-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-fable-5" }, + { "pattern": "^claude-sonnet-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-sonnet-5" }, + { "pattern": "^gemini-2\\.5-flash$", "canonical": "gemini-2.5-flash" }, + { "pattern": "^gemini-3-flash(?:-preview)?$", "canonical": "gemini-3-flash-preview" }, + { "pattern": "^gemini-3\\.5-flash(?:-preview)?$", "canonical": "gemini-3.5-flash" }, + { "pattern": "^gemini-3-pro-preview$", "canonical": "gemini-3-pro-preview" }, + { "pattern": "^gemini-3\\.1-pro$", "canonical": "gemini-3.1-pro-preview" }, + { "pattern": "^gemini-3\\.1-pro-preview$", "canonical": "gemini-3.1-pro-preview" }, + { "pattern": "^gpt-5(?:-(?:low|high))?-fast$", "canonical": "gpt-5-fast" }, + { "pattern": "^gpt-5(?:-(?:low|high))?$", "canonical": "gpt-5" }, + { "pattern": "^gpt-5\\.1(?:-(?:low|high))?-fast$", "canonical": "gpt-5-fast" }, + { "pattern": "^gpt-5\\.1(?:-(?:low|high))?$", "canonical": "gpt-5" }, + { "pattern": "^gpt-5-mini$", "canonical": "gpt-5-mini" }, + { "pattern": "^grok-4-20(?:-thinking)?$", "canonical": "xai/grok-4.20-0309-reasoning" }, + { "pattern": "^grok-4\\.3(?:-thinking)?$", "canonical": "xai/grok-4.3" }, + { "pattern": "^grok-build-0\\.1$", "canonical": "grok-build-0.1" }, + { "pattern": "^grok-code-fast-1$", "canonical": "grok-build-0.1" }, + { "pattern": "^grok-build$", "canonical": "grok-build-0.1" }, + { "pattern": "^grok-composer-2\\.5-fast$", "canonical": "composer-2.5-fast" }, + { "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?-fast$", "canonical": "gpt-5.1-codex-max-fast" }, + { "pattern": "^gpt-5\\.1-codex-max(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex-max" }, + { "pattern": "^gpt-5\\.1-codex-mini(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex-mini" }, + { "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?-fast$", "canonical": "gpt-5.1-codex-fast" }, + { "pattern": "^gpt-5\\.1-codex(?:-(?:low|medium|high|xhigh))?$", "canonical": "gpt-5.1-codex" }, + { "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.2-codex-fast" }, + { "pattern": "^gpt-5\\.2-codex(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.2-codex" }, + { "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.2-fast" }, + { "pattern": "^gpt-5\\.2(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.2" }, + { "pattern": "^gpt-5\\.3-codex-spark-preview(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.3-codex-spark" }, + { "pattern": "^gpt-5\\.3-spark$", "canonical": "gpt-5.3-codex-spark" }, + { "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?-fast$", "canonical": "gpt-5.3-codex-fast" }, + { "pattern": "^gpt-5\\.3-codex(?:-(?:low|high|xhigh))?$", "canonical": "gpt-5.3-codex" }, + { "pattern": "^gpt-5\\.4-mini(?:-(?:none|low|medium|high|xhigh))?$", "canonical": "gpt-5.4-mini" }, + { "pattern": "^gpt-5\\.4-nano(?:-(?:none|low|medium|high|xhigh))?$", "canonical": "gpt-5.4-nano" }, + { "pattern": "^gpt-5\\.4-(?:low|medium|high|xhigh)-fast$", "canonical": "gpt-5.4-fast" }, + { "pattern": "^gpt-5\\.4-(?:low|medium|high|xhigh)$", "canonical": "gpt-5.4" }, + { "pattern": "^gpt-5\\.5(?:-(?:low|medium|high|xhigh|extra-high))?-fast$", "canonical": "gpt-5.5-fast" }, + { "pattern": "^gpt-5\\.5(?:-(?:low|medium|high|xhigh|extra-high))?$", "canonical": "gpt-5.5" }, + { "pattern": "^kimi-k2\\.5$", "canonical": "moonshot/kimi-k2.5" }, + { "pattern": "^kimi-k2p5$", "canonical": "moonshot/kimi-k2.5" }, + { "pattern": "^glm-5\\.2(?:-(?:high|max))?$", "canonical": "glm-5.2" }, + { "pattern": "^[Pp]remium \\((?:[Gg][Pp][Tt]-5\\.3-[Cc]odex|[Cc]odex 5\\.3)\\)$", "canonical": "gpt-5.3-codex" } + ] +} diff --git a/crates/openusage-core/src/claude_usage_scanner.rs b/crates/openusage-core/src/claude_usage_scanner.rs new file mode 100644 index 000000000..14f9d353c --- /dev/null +++ b/crates/openusage-core/src/claude_usage_scanner.rs @@ -0,0 +1,586 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Native Claude Code session log scanner (ports upstream ClaudeLogUsageScanner). + +use crate::log_usage_types::{ + DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, expand_tilde, host_query_response, + local_day_key_from_offset, since_local_midnight, +}; +use crate::model_pricing::{ModelPricing, default_pricing}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; +use time::OffsetDateTime; + +static FILE_CACHE: Mutex>> = Mutex::new(None); + +struct CachedFile { + size: u64, + mtime: SystemTime, + entries: Vec, +} + +#[derive(Clone)] +struct ClaudeEntry { + timestamp: OffsetDateTime, + tokens: TokenBreakdown, + message_id: Option, + request_id: Option, + is_sidechain: bool, + has_speed: bool, + cost_usd: Option, + model: Option, +} + +struct DiscoveredFile { + path: PathBuf, + size: u64, + mtime: SystemTime, +} + +pub fn query_daily_since( + since_compact: &str, + home_path: Option<&str>, +) -> (LogScanStatus, Vec) { + let since = parse_since(since_compact); + let pricing = default_pricing(); + match scan(days_back_from_since(since), home_path, pricing) { + Some(rows) if !rows.is_empty() => (LogScanStatus::Ok, rows), + _ => (LogScanStatus::NoData, vec![]), + } +} + +pub fn query_daily_host_json(opts_json: &str) -> String { + let v: serde_json::Value = serde_json::from_str(opts_json).unwrap_or(serde_json::json!({})); + let since = v.get("since").and_then(|s| s.as_str()).unwrap_or(""); + let home_path = v.get("homePath").and_then(|s| s.as_str()); + let (status, daily) = query_daily_since(since, home_path); + host_query_response(status, daily) +} + +fn parse_since(since: &str) -> OffsetDateTime { + let digits: String = since.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + if let (Ok(y), Ok(m), Ok(d)) = ( + digits[0..4].parse::(), + digits[4..6].parse::(), + digits[6..8].parse::(), + ) { + if let Ok(month) = time::Month::try_from(m) { + if let Ok(date) = time::Date::from_calendar_date(y, month, d) { + return date.with_hms(0, 0, 0).expect("midnight").assume_utc(); + } + } + } + } + since_local_midnight(30) +} + +fn days_back_from_since(since: OffsetDateTime) -> i32 { + let now = OffsetDateTime::now_utc(); + ((now.date() - since.date()).whole_days().max(0) + 1) as i32 +} + +fn scan( + days_back: i32, + home_path: Option<&str>, + pricing: &ModelPricing, +) -> Option> { + let roots = claude_roots(home_path); + if roots.is_empty() { + return None; + } + let files = usage_files(&roots); + if files.is_empty() { + return None; + } + let since = since_local_midnight(days_back); + let mut entries = Vec::new(); + if let Ok(mut guard) = FILE_CACHE.lock() { + let cache = guard.get_or_insert_with(HashMap::new); + let mut next_cache = HashMap::new(); + for file in &files { + if file_mtime_before(&file.mtime, since) { + continue; + } + let key = file.path.to_string_lossy().to_string(); + let file_entries = if let Some(cached) = cache.get(&key) { + if cached.size == file.size && cached.mtime == file.mtime { + cached.entries.clone() + } else { + parse_file(&file.path) + } + } else { + parse_file(&file.path) + }; + next_cache.insert( + key, + CachedFile { + size: file.size, + mtime: file.mtime, + entries: file_entries.clone(), + }, + ); + entries.extend(file_entries); + } + *cache = next_cache; + } + Some(aggregate(&dedup(entries), since, pricing)) +} + +fn claude_roots(home_path: Option<&str>) -> Vec { + let mut roots = Vec::new(); + let mut seen = HashSet::new(); + + fn push_root(roots: &mut Vec, seen: &mut HashSet, url: PathBuf) { + if url.join("projects").is_dir() { + let key = url.to_string_lossy().to_string(); + if seen.insert(key) { + roots.push(url); + } + } + } + + let env_or_arg = home_path + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .or_else(|| std::env::var("CLAUDE_CONFIG_DIR").ok()); + + if let Some(raw) = env_or_arg.filter(|s| !s.trim().is_empty()) { + for part in raw.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let mut url = expand_tilde(part); + if url.file_name().is_some_and(|n| n == "projects") && url.is_dir() { + if let Some(parent) = url.parent() { + url = parent.to_path_buf(); + } + } + push_root(&mut roots, &mut seen, url); + } + } + + if roots.is_empty() { + if let Some(home) = dirs::home_dir() { + let xdg = std::env::var("XDG_CONFIG_HOME") + .ok() + .filter(|s| !s.is_empty()) + .map(|s| expand_tilde(&s)) + .unwrap_or_else(|| home.join(".config")); + push_root(&mut roots, &mut seen, xdg.join("claude")); + push_root(&mut roots, &mut seen, home.join(".claude")); + for sandbox in cowork_claude_dirs(&home) { + push_root(&mut roots, &mut seen, sandbox); + } + } + } + + roots +} + +fn cowork_claude_dirs(home: &Path) -> Vec { + let base = home.join("Library/Application Support/Claude/local-agent-mode-sessions"); + if !base.is_dir() { + return vec![]; + } + let mut dirs = Vec::new(); + let Ok(groups) = fs::read_dir(&base) else { + return vec![]; + }; + for group in groups.flatten() { + let Ok(subs) = fs::read_dir(group.path()) else { + continue; + }; + for sub in subs.flatten() { + let mut sessions: Vec = fs::read_dir(sub.path()) + .ok() + .map(|rd| rd.flatten().map(|e| e.path()).collect()) + .unwrap_or_default(); + for holder in sessions.clone() { + if holder.file_name().is_some_and(|n| n == "agent") { + if let Ok(agent_children) = fs::read_dir(&holder) { + sessions.extend(agent_children.flatten().map(|e| e.path())); + } + } + } + for session in sessions { + dirs.push(session.join(".claude")); + } + } + } + dirs.sort(); + dirs +} + +fn usage_files(roots: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for root in roots { + walk_jsonl(&root.join("projects"), &mut files); + } + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +fn walk_jsonl(dir: &Path, out: &mut Vec) { + let Ok(rd) = fs::read_dir(dir) else { + return; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_jsonl(&path, out); + } else if path.extension().is_some_and(|e| e == "jsonl") { + if let Ok(meta) = entry.metadata() { + out.push(DiscoveredFile { + path, + size: meta.len(), + mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), + }); + } + } + } +} + +fn file_mtime_before(mtime: &SystemTime, since: OffsetDateTime) -> bool { + let Ok(duration) = mtime.duration_since(SystemTime::UNIX_EPOCH) else { + return false; + }; + let Ok(file_dt) = OffsetDateTime::from_unix_timestamp(duration.as_secs() as i64) else { + return false; + }; + file_dt < since +} + +fn parse_file(path: &Path) -> Vec { + let Ok(data) = fs::read(path) else { + return vec![]; + }; + let marker = br#""usage":{"#; + let mut entries = Vec::new(); + for line in data.split(|&b| b == b'\n') { + if !line.windows(marker.len()).any(|w| w == marker) { + continue; + } + if has_unsupported_null_field(line) { + continue; + } + if let Some(entry) = parse_line(line) { + entries.push(entry); + } + } + entries +} + +fn parse_line(line: &[u8]) -> Option { + let v: Value = serde_json::from_slice(line).ok()?; + let timestamp_raw = v.get("timestamp")?.as_str()?; + let timestamp = parse_iso_timestamp(timestamp_raw)?; + let message = v.get("message")?; + let usage = message.get("usage")?; + let input = usage.get("input_tokens")?.as_i64()? as i32; + let output = usage.get("output_tokens")?.as_i64()? as i32; + let speed = usage.get("speed").and_then(|s| s.as_str()); + if let Some(s) = speed { + if s != "fast" && s != "standard" { + return None; + } + } + if !is_valid_entry(&v, message) { + return None; + } + let mut cache_write5m = 0; + let mut cache_write1h = 0; + if let Some(cache_creation) = usage.get("cache_creation") { + cache_write5m = cache_creation + .get("ephemeral_5m_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32; + cache_write1h = cache_creation + .get("ephemeral_1h_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32; + } else { + cache_write5m = usage + .get("cache_creation_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32; + } + let tokens = TokenBreakdown { + input, + cache_write5m, + cache_write1h, + cache_read: usage + .get("cache_read_input_tokens") + .and_then(|n| n.as_i64()) + .unwrap_or(0) as i32, + output, + is_fast: speed == Some("fast"), + }; + let model = message + .get("model") + .and_then(|m| m.as_str()) + .filter(|m| *m != "") + .map(str::to_string); + Some(ClaudeEntry { + timestamp, + tokens, + message_id: message + .get("id") + .and_then(|id| id.as_str()) + .map(str::to_string), + request_id: v + .get("requestId") + .and_then(|id| id.as_str()) + .map(str::to_string), + is_sidechain: v + .get("isSidechain") + .and_then(|b| b.as_bool()) + .unwrap_or(false), + has_speed: speed.is_some(), + cost_usd: v.get("costUSD").and_then(|c| c.as_f64()), + model, + }) +} + +fn is_valid_entry(object: &Value, message: &Value) -> bool { + if let Some(version) = object.get("version").and_then(|v| v.as_str()) { + if !is_semver_prefix(version) { + return false; + } + } + for key in ["sessionId", "requestId"] { + if let Some(text) = object.get(key).and_then(|v| v.as_str()) { + if text.is_empty() { + return false; + } + } + } + for key in ["id", "model"] { + if let Some(text) = message.get(key).and_then(|v| v.as_str()) { + if text.is_empty() { + return false; + } + } + } + true +} + +fn is_semver_prefix(value: &str) -> bool { + let parts: Vec<&str> = value.split('.').collect(); + parts.len() >= 3 + && parts[0].chars().all(|c| c.is_ascii_digit()) + && parts[1].chars().all(|c| c.is_ascii_digit()) + && parts[2].chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +fn has_unsupported_null_field(line: &[u8]) -> bool { + const FIELDS: &[&str] = &[ + "id", + "cwd", + "model", + "speed", + "costUSD", + "version", + "sessionId", + "requestId", + "isApiErrorMessage", + "cache_read_input_tokens", + "cache_creation_input_tokens", + ]; + let text = String::from_utf8_lossy(line); + FIELDS + .iter() + .any(|field| text.contains(&format!("\"{field}\":null"))) +} + +pub fn parse_iso_timestamp(raw: &str) -> Option { + time::OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339).ok() +} + +#[derive(Hash, PartialEq, Eq)] +struct ExactKey { + message_id: String, + request_id: Option, +} + +fn dedup(entries: Vec) -> Vec { + let mut deduped = Vec::new(); + let mut exact_index: HashMap = HashMap::new(); + let mut message_index: HashMap> = HashMap::new(); + + for entry in entries { + let Some(message_id) = entry.message_id.clone() else { + deduped.push(entry); + continue; + }; + let key = ExactKey { + message_id: message_id.clone(), + request_id: entry.request_id.clone(), + }; + let collision = exact_index.get(&key).copied().or_else(|| { + message_index.get(&message_id).and_then(|indices| { + indices + .iter() + .copied() + .find(|&idx| entry.is_sidechain || deduped[idx].is_sidechain) + }) + }); + + if let Some(index) = collision { + if should_replace(&entry, &deduped[index]) { + let old = &deduped[index]; + if let Some(old_id) = &old.message_id { + exact_index.remove(&ExactKey { + message_id: old_id.clone(), + request_id: old.request_id.clone(), + }); + } + deduped[index] = entry; + exact_index.insert(key, index); + } + continue; + } + + let index = deduped.len(); + deduped.push(entry); + exact_index.insert(key, index); + message_index.entry(message_id).or_default().push(index); + } + deduped +} + +fn should_replace(candidate: &ClaudeEntry, existing: &ClaudeEntry) -> bool { + if candidate.is_sidechain != existing.is_sidechain { + return existing.is_sidechain; + } + let ct = candidate.tokens.total_tokens(); + let et = existing.tokens.total_tokens(); + if ct != et { + return ct > et; + } + candidate.has_speed && !existing.has_speed +} + +fn aggregate( + entries: &[ClaudeEntry], + since: OffsetDateTime, + pricing: &ModelPricing, +) -> Vec { + let mut tokens_by_day: BTreeMap = BTreeMap::new(); + let mut cost_by_day: BTreeMap = BTreeMap::new(); + let mut priced_days: HashSet = HashSet::new(); + let mut models_by_day: BTreeMap> = BTreeMap::new(); + let mut input_by_day: BTreeMap = BTreeMap::new(); + let mut output_by_day: BTreeMap = BTreeMap::new(); + let mut cache_create_by_day: BTreeMap = BTreeMap::new(); + let mut cache_read_by_day: BTreeMap = BTreeMap::new(); + + for entry in entries { + if entry.timestamp < since { + continue; + } + let day = local_day_key_from_offset(&entry.timestamp); + let model_name = entry + .model + .as_deref() + .map(str::trim) + .filter(|m| !m.is_empty()) + .unwrap_or("unattributed"); + + let cost = if let Some(carried) = entry.cost_usd { + carried + } else if let Some(model) = entry.model.as_deref().filter(|m| !m.trim().is_empty()) { + match pricing.estimated_cost_dollars(model, &entry.tokens) { + Some(c) => c, + None => continue, + } + } else { + continue; + }; + + let total = entry.tokens.total_tokens(); + *tokens_by_day.entry(day.clone()).or_default() += total; + *cost_by_day.entry(day.clone()).or_default() += cost; + priced_days.insert(day.clone()); + *input_by_day.entry(day.clone()).or_default() += entry.tokens.input; + *output_by_day.entry(day.clone()).or_default() += entry.tokens.output; + *cache_create_by_day.entry(day.clone()).or_default() += + entry.tokens.cache_write5m + entry.tokens.cache_write1h; + *cache_read_by_day.entry(day.clone()).or_default() += entry.tokens.cache_read; + + let slot = models_by_day.entry(day.clone()).or_default(); + let e = slot.entry(model_name.to_string()).or_insert((0, 0.0)); + e.0 += total; + e.1 += cost; + } + + tokens_by_day + .keys() + .rev() + .cloned() + .map(|day| { + let total_cost = if priced_days.contains(&day) { + cost_by_day.get(&day).copied() + } else { + None + }; + let models: BTreeMap = models_by_day + .get(&day) + .map(|m| { + m.iter() + .map(|(name, (tokens, cost))| { + ( + name.clone(), + ModelDayUsage { + input_tokens: 0, + output_tokens: *tokens, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: *tokens, + total_cost: Some(*cost), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + DailyUsageRow { + date: day.clone(), + input_tokens: *input_by_day.get(&day).unwrap_or(&0), + output_tokens: *output_by_day.get(&day).unwrap_or(&0), + cache_creation_tokens: *cache_create_by_day.get(&day).unwrap_or(&0), + cache_read_tokens: *cache_read_by_day.get(&day).unwrap_or(&0), + total_tokens: *tokens_by_day.get(&day).unwrap_or(&0), + total_cost, + cost_usd: total_cost, + models, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn parses_usage_line_and_aggregates() { + let tmp = TempDir::new().unwrap(); + let projects = tmp.path().join("projects").join("demo"); + fs::create_dir_all(&projects).unwrap(); + let log = projects.join("sess.jsonl"); + let line = r#"{"timestamp":"2026-07-06T12:00:00Z","message":{"id":"m1","model":"claude-sonnet-4-20250514","usage":{"input_tokens":100,"output_tokens":50}},"requestId":"r1","version":"1.0.0"}"#; + let mut f = fs::File::create(&log).unwrap(); + writeln!(f, "{line}").unwrap(); + + let rows = scan(3650, Some(&tmp.path().to_string_lossy()), default_pricing()).unwrap(); + assert!(!rows.is_empty()); + assert!(rows[0].total_tokens >= 150); + } +} diff --git a/crates/openusage-core/src/codex_usage_scanner.rs b/crates/openusage-core/src/codex_usage_scanner.rs new file mode 100644 index 000000000..b92648cb0 --- /dev/null +++ b/crates/openusage-core/src/codex_usage_scanner.rs @@ -0,0 +1,571 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Native Codex CLI session log scanner (ports upstream CodexLogUsageScanner). + +use crate::claude_usage_scanner; +use crate::log_usage_types::{ + DailyUsageRow, LogScanStatus, ModelDayUsage, TokenBreakdown, expand_tilde, host_query_response, + local_day_key_from_offset, since_local_midnight, +}; +use crate::model_pricing::{ModelPricing, default_pricing}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::SystemTime; +use time::OffsetDateTime; + +static FILE_CACHE: Mutex>> = Mutex::new(None); + +struct CachedFile { + size: u64, + mtime: SystemTime, + events: Vec, +} + +#[derive(Clone)] +struct CodexEvent { + timestamp: OffsetDateTime, + model: String, + input: i32, + cached: i32, + output: i32, + reasoning: i32, + total: i32, +} + +struct DiscoveredFile { + path: PathBuf, + size: u64, + mtime: SystemTime, + relative: String, +} + +pub fn query_daily_since( + since_compact: &str, + home_path: Option<&str>, +) -> (LogScanStatus, Vec) { + let since = parse_since(since_compact); + let pricing = default_pricing(); + match scan(days_back_from_since(since), home_path, pricing) { + Some(rows) if !rows.is_empty() => (LogScanStatus::Ok, rows), + _ => (LogScanStatus::NoData, vec![]), + } +} + +pub fn query_daily_host_json(opts_json: &str) -> String { + let v: serde_json::Value = serde_json::from_str(opts_json).unwrap_or(serde_json::json!({})); + let since = v.get("since").and_then(|s| s.as_str()).unwrap_or(""); + let home_path = v.get("homePath").and_then(|s| s.as_str()); + let (status, daily) = query_daily_since(since, home_path); + host_query_response(status, daily) +} + +fn parse_since(since: &str) -> OffsetDateTime { + let digits: String = since.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + if let (Ok(y), Ok(m), Ok(d)) = ( + digits[0..4].parse::(), + digits[4..6].parse::(), + digits[6..8].parse::(), + ) { + if let Ok(month) = time::Month::try_from(m) { + if let Ok(date) = time::Date::from_calendar_date(y, month, d) { + return date.with_hms(0, 0, 0).expect("midnight").assume_utc(); + } + } + } + } + since_local_midnight(30) +} + +fn days_back_from_since(since: OffsetDateTime) -> i32 { + let now = OffsetDateTime::now_utc(); + ((now.date() - since.date()).whole_days().max(0) + 1) as i32 +} + +fn scan( + days_back: i32, + home_path: Option<&str>, + pricing: &ModelPricing, +) -> Option> { + let homes = codex_homes(home_path); + let files = session_files(&homes); + if files.is_empty() { + return None; + } + let since = since_local_midnight(days_back); + let mut events = Vec::new(); + if let Ok(mut guard) = FILE_CACHE.lock() { + let cache = guard.get_or_insert_with(HashMap::new); + let mut next_cache = HashMap::new(); + for file in &files { + if file_mtime_before(&file.mtime, since) { + continue; + } + let key = file.path.to_string_lossy().to_string(); + let file_events = if let Some(cached) = cache.get(&key) { + if cached.size == file.size && cached.mtime == file.mtime { + cached.events.clone() + } else { + parse_file(&file.path) + } + } else { + parse_file(&file.path) + }; + next_cache.insert( + key, + CachedFile { + size: file.size, + mtime: file.mtime, + events: file_events.clone(), + }, + ); + events.extend(file_events); + } + *cache = next_cache; + } + Some(aggregate(&dedup_events(events), since, pricing)) +} + +fn codex_homes(home_path: Option<&str>) -> Vec { + let raw = home_path + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .or_else(|| std::env::var("CODEX_HOME").ok()); + if let Some(raw) = raw.filter(|s| !s.trim().is_empty()) { + return raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(expand_tilde) + .collect(); + } + dirs::home_dir() + .map(|h| vec![h.join(".codex")]) + .unwrap_or_default() +} + +fn session_files(homes: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + let mut seen_relative: HashSet<(String, String)> = HashSet::new(); + for home in homes { + for sub in ["sessions", "archived_sessions"] { + let dir = home.join(sub); + if !dir.is_dir() { + continue; + } + let home_key = home.to_string_lossy().to_string(); + collect_session_files(&dir, &dir, &home_key, sub, &mut files, &mut seen_relative); + } + if !home.join("sessions").is_dir() && !home.join("archived_sessions").is_dir() { + let home_key = home.to_string_lossy().to_string(); + collect_session_files(home, home, &home_key, "", &mut files, &mut seen_relative); + } + } + files.sort_by(|a, b| a.path.cmp(&b.path)); + files +} + +fn collect_session_files( + root: &Path, + dir: &Path, + home_key: &str, + source: &str, + out: &mut Vec, + seen: &mut HashSet<(String, String)>, +) { + let Ok(rd) = fs::read_dir(dir) else { + return; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_session_files(root, &path, home_key, source, out, seen); + } else if path.extension().is_some_and(|e| e == "jsonl") { + let rel = path + .strip_prefix(root) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| path.to_string_lossy().to_string()); + let key = (home_key.to_string(), rel.clone()); + if source == "archived_sessions" && seen.contains(&(home_key.to_string(), rel.clone())) + { + continue; + } + if source == "sessions" { + seen.insert(key.clone()); + } + if seen.insert(key) { + if let Ok(meta) = entry.metadata() { + out.push(DiscoveredFile { + path, + size: meta.len(), + mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), + relative: rel, + }); + } + } + } + } +} + +fn file_mtime_before(mtime: &SystemTime, since: OffsetDateTime) -> bool { + let Ok(duration) = mtime.duration_since(SystemTime::UNIX_EPOCH) else { + return false; + }; + let Ok(file_dt) = OffsetDateTime::from_unix_timestamp(duration.as_secs() as i64) else { + return false; + }; + file_dt < since +} + +#[derive(Clone, Default)] +struct RawUsage { + input: i32, + cached: i32, + output: i32, + reasoning: i32, + total: i32, +} + +impl RawUsage { + fn from_json(json: &serde_json::Map) -> Self { + fn int(json: &serde_json::Map, keys: &[&str]) -> i32 { + for key in keys { + if let Some(n) = json.get(*key).and_then(|v| v.as_i64()) { + return n as i32; + } + } + 0 + } + let input = int(json, &["input_tokens", "prompt_tokens", "input"]); + let cached = int( + json, + &[ + "cached_input_tokens", + "cache_read_input_tokens", + "cached_tokens", + ], + ); + let output = int(json, &["output_tokens", "completion_tokens", "output"]); + let reasoning = int(json, &["reasoning_output_tokens", "reasoning_tokens"]); + let reported = int(json, &["total_tokens"]); + let recomputed = input + output + reasoning; + let total = if reported > 0 || recomputed == 0 { + reported + } else { + recomputed + }; + Self { + input, + cached, + output, + reasoning, + total, + } + } + + fn subtracting(&self, previous: Option<&RawUsage>) -> RawUsage { + let p = previous.cloned().unwrap_or_default(); + RawUsage { + input: (self.input - p.input).max(0), + cached: (self.cached - p.cached).max(0), + output: (self.output - p.output).max(0), + reasoning: (self.reasoning - p.reasoning).max(0), + total: (self.total - p.total).max(0), + } + } +} + +fn parse_file(path: &Path) -> Vec { + let Ok(data) = fs::read(path) else { + return vec![]; + }; + let subagent = + data.len() >= 16 * 1024 && data[..16 * 1024].windows(12).any(|w| w == b"thread_spawn"); + let replay_second = if subagent { + detect_subagent_replay_second(&data) + } else { + None + }; + let turn_marker = br#""type":"turn_context""#; + let token_marker = br#""type":"token_count""#; + let mut events = Vec::new(); + let mut previous_totals: Option = None; + let mut current_model: Option = None; + let mut skip_replay = replay_second.is_some(); + + for line in data.split(|&b| b == b'\n') { + let is_turn = line.windows(turn_marker.len()).any(|w| w == turn_marker); + if !is_turn && !line.windows(token_marker.len()).any(|w| w == token_marker) { + continue; + } + let Ok(v) = serde_json::from_slice::(line) else { + continue; + }; + let Some(obj) = v.as_object() else { + continue; + }; + let typ = obj.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let payload = obj.get("payload").and_then(|p| p.as_object()); + + if typ == "turn_context" { + if let Some(p) = payload { + if let Some(model) = model_name_in_map(p) { + current_model = Some(model); + } + } + continue; + } + if typ != "event_msg" { + continue; + } + let Some(p) = payload else { + continue; + }; + if p.get("type").and_then(|t| t.as_str()) != Some("token_count") { + continue; + } + let Some(timestamp_raw) = obj.get("timestamp").and_then(|t| t.as_str()) else { + continue; + }; + let Some(timestamp) = claude_usage_scanner::parse_iso_timestamp(timestamp_raw.trim()) + else { + continue; + }; + let info = p.get("info").and_then(|i| i.as_object()); + let totals = info + .and_then(|i| i.get("total_token_usage")) + .and_then(|u| u.as_object()) + .map(RawUsage::from_json); + + if skip_replay { + if let Some(replay) = &replay_second { + if timestamp_raw.trim().get(..19) == Some(replay.as_str()) { + if let Some(t) = totals { + previous_totals = Some(t); + } + continue; + } + skip_replay = false; + } + } + + let usage = if let Some(info) = info { + if let Some(last) = info.get("last_token_usage").and_then(|u| u.as_object()) { + RawUsage::from_json(last) + } else if let Some(ref t) = totals { + t.subtracting(previous_totals.as_ref()) + } else { + continue; + } + } else { + continue; + }; + if let Some(ref t) = totals { + previous_totals = Some(t.clone()); + } + if usage.input <= 0 && usage.cached <= 0 && usage.output <= 0 && usage.reasoning <= 0 { + continue; + } + let parsed_model = model_name_in_map(p).or_else(|| info.and_then(model_name_in_map)); + let model = resolve_model(parsed_model, &mut current_model); + let cached = usage.cached.min(usage.input); + events.push(CodexEvent { + timestamp, + model, + input: usage.input, + cached, + output: usage.output, + reasoning: usage.reasoning, + total: usage.total, + }); + } + events +} + +fn model_name_in_map(json: &serde_json::Map) -> Option { + for key in ["model", "model_name"] { + if let Some(text) = json.get(key).and_then(|v| v.as_str()) { + let t = text.trim(); + if !t.is_empty() { + return Some(t.to_string()); + } + } + } + json.get("metadata") + .and_then(|m| m.get("model")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn resolve_model(parsed: Option, current_model: &mut Option) -> String { + if let Some(parsed) = parsed { + *current_model = Some(parsed.clone()); + if parsed == "codex-auto-review" { + return "gpt-5.3-codex".to_string(); + } + return parsed; + } + current_model.clone().unwrap_or_else(|| { + let fallback = "gpt-5".to_string(); + *current_model = Some(fallback.clone()); + fallback + }) +} + +fn detect_subagent_replay_second(data: &[u8]) -> Option { + let marker = br#""type":"token_count""#; + let mut first_second: Option = None; + for line in data.split(|&b| b == b'\n') { + if !line.windows(marker.len()).any(|w| w == marker) { + continue; + } + let Ok(v) = serde_json::from_slice::(line) else { + continue; + }; + let Some(obj) = v.as_object() else { + continue; + }; + if obj.get("type").and_then(|t| t.as_str()) != Some("event_msg") { + continue; + } + let Some(payload) = obj.get("payload").and_then(|p| p.as_object()) else { + continue; + }; + if payload.get("type").and_then(|t| t.as_str()) != Some("token_count") { + continue; + } + let Some(info) = payload.get("info").and_then(|i| i.as_object()) else { + continue; + }; + if info.get("last_token_usage").is_none() && info.get("total_token_usage").is_none() { + continue; + } + let Some(ts) = obj.get("timestamp").and_then(|t| t.as_str()) else { + continue; + }; + let ts = ts.trim(); + if ts.len() < 19 { + continue; + } + let second = ts[..19].to_string(); + match &first_second { + None => first_second = Some(second), + Some(first) if first == &second => return Some(second), + _ => return None, + } + } + None +} + +fn dedup_events(events: Vec) -> Vec { + let mut seen: HashSet<(i128, String, i32, i32, i32, i32)> = HashSet::new(); + let mut out = Vec::new(); + for e in events { + let key = ( + e.timestamp.unix_timestamp_nanos(), + e.model.clone(), + e.input, + e.cached, + e.output, + e.reasoning, + ); + if seen.insert(key) { + out.push(e); + } + } + out +} + +fn aggregate( + events: &[CodexEvent], + since: OffsetDateTime, + pricing: &ModelPricing, +) -> Vec { + let mut tokens_by_day: BTreeMap = BTreeMap::new(); + let mut cost_by_day: BTreeMap = BTreeMap::new(); + let mut priced_days: HashSet = HashSet::new(); + let mut models_by_day: BTreeMap> = BTreeMap::new(); + let mut input_by_day: BTreeMap = BTreeMap::new(); + let mut output_by_day: BTreeMap = BTreeMap::new(); + + for event in events { + if event.timestamp < since { + continue; + } + let day = local_day_key_from_offset(&event.timestamp); + let tokens = TokenBreakdown { + input: (event.input - event.cached).max(0), + cache_write5m: 0, + cache_write1h: 0, + cache_read: event.cached, + output: event.output + event.reasoning, + is_fast: false, + }; + let cost = match pricing.estimated_cost_dollars(&event.model, &tokens) { + Some(c) => c, + None => continue, + }; + let total = if event.total > 0 { + event.total + } else { + tokens.total_tokens() + }; + *tokens_by_day.entry(day.clone()).or_default() += total; + *cost_by_day.entry(day.clone()).or_default() += cost; + priced_days.insert(day.clone()); + *input_by_day.entry(day.clone()).or_default() += event.input; + *output_by_day.entry(day.clone()).or_default() += event.output + event.reasoning; + let slot = models_by_day.entry(day.clone()).or_default(); + let e = slot.entry(event.model.clone()).or_insert((0, 0.0)); + e.0 += total; + e.1 += cost; + } + + tokens_by_day + .keys() + .rev() + .cloned() + .map(|day| { + let total_cost = if priced_days.contains(&day) { + cost_by_day.get(&day).copied() + } else { + None + }; + let models: BTreeMap = models_by_day + .get(&day) + .map(|m| { + m.iter() + .map(|(name, (tokens, cost))| { + ( + name.clone(), + ModelDayUsage { + input_tokens: 0, + output_tokens: *tokens, + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: *tokens, + total_cost: Some(*cost), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + DailyUsageRow { + date: day.clone(), + input_tokens: *input_by_day.get(&day).unwrap_or(&0), + output_tokens: *output_by_day.get(&day).unwrap_or(&0), + cache_creation_tokens: 0, + cache_read_tokens: 0, + total_tokens: *tokens_by_day.get(&day).unwrap_or(&0), + total_cost, + cost_usd: total_cost, + models, + } + }) + .collect() +} diff --git a/crates/openusage-core/src/cursor_paths.rs b/crates/openusage-core/src/cursor_paths.rs new file mode 100644 index 000000000..e36d14f60 --- /dev/null +++ b/crates/openusage-core/src/cursor_paths.rs @@ -0,0 +1,154 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Cursor Stable vs Cursor Nightly install paths (separate `state.vscdb` per app). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags}; + +const STATE_DB_SUFFIX: &str = "User/globalStorage/state.vscdb"; +const ACCESS_KEY: &str = "cursorAuth/accessToken"; +const REFRESH_KEY: &str = "cursorAuth/refreshToken"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorInstall { + Stable, + Nightly, +} + +impl CursorInstall { + pub fn app_dir_name(self) -> &'static str { + match self { + Self::Stable => "Cursor", + Self::Nightly => "Cursor Nightly", + } + } + + pub fn from_plugin_id(plugin_id: &str) -> Option { + match plugin_id.trim() { + "cursor" => Some(Self::Stable), + "cursor-nightly" => Some(Self::Nightly), + _ => None, + } + } +} + +fn expand_home(path: &str) -> PathBuf { + let trimmed = path.trim(); + if trimmed == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(trimmed) +} + +fn platform_roots() -> Vec { + vec![ + expand_home("~/.config"), + expand_home("~/Library/Application Support"), + expand_home("~/AppData/Roaming"), + ] +} + +/// `state.vscdb` for one install only (stable **or** nightly — never merged). +pub fn resolve_cursor_state_db_for(install: CursorInstall) -> Option { + if let Ok(custom) = std::env::var("CURSOR_STATE_DB") { + let custom = custom.trim(); + if !custom.is_empty() { + let p = expand_home(custom); + if p.is_file() { + return Some(p); + } + } + } + for root in platform_roots() { + let p = root.join(install.app_dir_name()).join(STATE_DB_SUFFIX); + if p.is_file() { + return Some(p); + } + } + None +} + +pub fn resolve_cursor_state_db_for_plugin_id(plugin_id: &str) -> Option { + CursorInstall::from_plugin_id(plugin_id).and_then(resolve_cursor_state_db_for) +} + +/// Default stable DB (CLI / legacy callers). +pub fn resolve_cursor_state_db() -> Option { + resolve_cursor_state_db_for(CursorInstall::Stable) + .or_else(|| resolve_cursor_state_db_for(CursorInstall::Nightly)) +} + +fn read_sqlite_value(db_path: &Path, key: &str) -> Option { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; + let mut stmt = conn + .prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1") + .ok()?; + let mut rows = stmt.query_map([key], |row| row.get::<_, String>(0)).ok()?; + let row = rows.next()?.ok()?; + let trimmed = row.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn cursor_state_db_has_auth(db_path: &Path) -> bool { + read_sqlite_value(db_path, ACCESS_KEY).is_some() + || read_sqlite_value(db_path, REFRESH_KEY).is_some() +} + +fn path_has_app_dir(db_path: &Path, app_name: &str) -> bool { + db_path + .components() + .any(|c| c.as_os_str() == OsStr::new(app_name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_and_nightly_resolve_to_distinct_linux_paths() { + let stable = resolve_cursor_state_db_for(CursorInstall::Stable) + .map(|p| p.to_string_lossy().to_string()); + let nightly = resolve_cursor_state_db_for(CursorInstall::Nightly) + .map(|p| p.to_string_lossy().to_string()); + let stable_path = expand_home("~/.config/Cursor/User/globalStorage/state.vscdb"); + let nightly_path = expand_home("~/.config/Cursor Nightly/User/globalStorage/state.vscdb"); + if stable_path.is_file() { + assert_eq!( + stable.as_deref(), + Some(stable_path.to_string_lossy().as_ref()) + ); + } + if nightly_path.is_file() { + assert_eq!( + nightly.as_deref(), + Some(nightly_path.to_string_lossy().as_ref()) + ); + } + if stable.is_some() && nightly.is_some() { + assert_ne!(stable, nightly); + } + } + + #[test] + fn from_plugin_id_maps_both_providers() { + assert_eq!( + CursorInstall::from_plugin_id("cursor"), + Some(CursorInstall::Stable) + ); + assert_eq!( + CursorInstall::from_plugin_id("cursor-nightly"), + Some(CursorInstall::Nightly) + ); + assert_eq!(CursorInstall::from_plugin_id("claude"), None); + } +} diff --git a/crates/openusage-core/src/cursor_usage_export.rs b/crates/openusage-core/src/cursor_usage_export.rs new file mode 100644 index 000000000..ec960652a --- /dev/null +++ b/crates/openusage-core/src/cursor_usage_export.rs @@ -0,0 +1,828 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Cursor token-level usage from the dashboard CSV export (same source as cstats). + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Utc}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const REFRESH_URL: &str = "https://api2.cursor.sh/oauth/token"; +const CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; +const EXPORT_URL: &str = "https://cursor.com/api/dashboard/export-usage-events-csv"; +const ACCESS_KEY: &str = "cursorAuth/accessToken"; +const REFRESH_KEY: &str = "cursorAuth/refreshToken"; +const REFRESH_BUFFER_MS: i64 = 5 * 60 * 1000; + +#[derive(Debug, Clone, Default)] +pub struct RowAgg { + pub input_no_cache: u64, + pub input_cache_write: u64, + pub cache_read: u64, + pub output: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone)] +pub struct CsvUsageRow { + /// Local calendar day `YYYY-MM-DD`. + day_key: String, + model: String, + input_cache_write: u64, + input_no_cache: u64, + cache_read: u64, + output_tokens: u64, + total_tokens: u64, + cost_usd: f64, +} + +fn parse_int_cell(s: &str) -> u64 { + let t = s.trim(); + if t.is_empty() { + return 0; + } + let digits: String = t.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.parse().unwrap_or(0) +} + +fn parse_cost_cell(s: &str) -> f64 { + let t = s.trim().trim_start_matches('$').replace(',', ""); + t.parse().unwrap_or(0.0) +} + +fn csv_date_to_yyyymmdd(raw: &str) -> Result { + let raw = raw.trim(); + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + let local = dt.with_timezone(&Local); + return Ok(format!( + "{:04}{:02}{:02}", + local.year(), + local.month(), + local.day() + )); + } + if let Ok(nd) = NaiveDate::parse_from_str(raw.split('T').next().unwrap_or(raw), "%Y-%m-%d") { + return Ok(format!("{:04}{:02}{:02}", nd.year(), nd.month(), nd.day())); + } + if raw.len() >= 10 && raw.as_bytes()[4] == b'-' && raw.as_bytes()[7] == b'-' { + if let Ok(nd) = NaiveDate::parse_from_str(&raw[..10], "%Y-%m-%d") { + return Ok(format!("{:04}{:02}{:02}", nd.year(), nd.month(), nd.day())); + } + } + bail!("Unrecognized CSV date: {raw:?}") +} + +fn row_in_range(date_yyyymmdd: &str, since: &str, until: &str) -> bool { + date_yyyymmdd >= since && date_yyyymmdd <= until +} + +pub fn parse_usage_csv(text: &str, since: &str, until: &str) -> Result> { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(text.as_bytes()); + + let headers = rdr.headers()?.clone(); + let required = [ + "Date", + "Model", + "Input (w/ Cache Write)", + "Input (w/o Cache Write)", + "Cache Read", + "Output Tokens", + "Total Tokens", + "Cost", + ]; + for h in required { + if !headers.iter().any(|x| x == h) { + bail!("Cursor CSV missing column {h:?}. Export format may have changed."); + } + } + + let col = |name: &str| -> Result { + headers + .iter() + .position(|h| h == name) + .with_context(|| format!("missing column {name}")) + }; + let i_date = col("Date")?; + let i_model = col("Model")?; + + let mut out = Vec::new(); + for rec in rdr.records() { + let rec = rec?; + let date_raw = rec.get(i_date).unwrap_or(""); + let date_yyyymmdd = csv_date_to_yyyymmdd(date_raw)?; + if !row_in_range(&date_yyyymmdd, since, until) { + continue; + } + let get = |name: &str| -> Result<&str> { + let i = headers + .iter() + .position(|h| h == name) + .with_context(|| format!("missing column {name}"))?; + Ok(rec.get(i).unwrap_or("")) + }; + let model = rec.get(i_model).unwrap_or("").trim().to_string(); + if model.is_empty() { + continue; + } + let input_cache_write = parse_int_cell(get("Input (w/ Cache Write)")?); + let input_no_cache = parse_int_cell(get("Input (w/o Cache Write)")?); + let cache_read = parse_int_cell(get("Cache Read")?); + let output_tokens = parse_int_cell(get("Output Tokens")?); + let total_tokens = parse_int_cell(get("Total Tokens")?); + let cost_usd = parse_cost_cell(get("Cost")?); + + let sum_tokens = input_cache_write + input_no_cache + cache_read + output_tokens; + if input_no_cache == 0 + && output_tokens == 0 + && input_cache_write == 0 + && cache_read == 0 + && total_tokens == 0 + && cost_usd == 0.0 + { + continue; + } + + let day_key = format!( + "{}-{}-{}", + &date_yyyymmdd[0..4], + &date_yyyymmdd[4..6], + &date_yyyymmdd[6..8] + ); + out.push(CsvUsageRow { + day_key, + model, + input_cache_write, + input_no_cache, + cache_read, + output_tokens, + total_tokens: if total_tokens > 0 { + total_tokens + } else { + sum_tokens + }, + cost_usd, + }); + } + Ok(out) +} + +pub fn aggregate_by_day(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let e = m.entry(row.day_key.clone()).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyBillingRow { + pub date: String, + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, +} + +/// Per-day totals from the Cursor dashboard billing CSV (includes cost USD). +pub fn query_daily_billing( + plugin_id: &str, + since: Option<&str>, + until: Option<&str>, +) -> Result> { + let (since, until) = if since.is_none() && until.is_none() { + let (s, u) = month_to_date_range_local()?; + (s, u) + } else { + resolve_date_range(since, until)? + }; + let csv_text = fetch_csv_cached(plugin_id, &since, &until)?; + let rows = parse_usage_csv(&csv_text, &since, &until)?; + let by_day = aggregate_by_day(&rows); + let mut out: Vec = by_day + .into_iter() + .map(|(date, agg)| DailyBillingRow { + date, + total_tokens: agg.total_tokens, + input_tokens: agg.input_no_cache + agg.input_cache_write + agg.cache_read, + output_tokens: agg.output, + cost_usd: agg.cost_usd, + }) + .collect(); + out.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(out) +} + +pub fn query_daily_billing_host_json(opts_json: &str) -> String { + let since = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("since").and_then(|s| s.as_str()).map(str::to_string)); + let until = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("until").and_then(|s| s.as_str()).map(str::to_string)); + let plugin_id = plugin_id_from_opts_json(opts_json); + match query_daily_billing(&plugin_id, since.as_deref(), until.as_deref()) { + Ok(daily) => serde_json::json!({ "status": "ok", "data": { "daily": daily } }).to_string(), + Err(e) => serde_json::json!({ + "status": "error", + "message": e.to_string() + }) + .to_string(), + } +} + +pub fn aggregate_by_model(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let e = m.entry(row.model.clone()).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +fn infer_provider(model: &str) -> String { + let s = model.to_lowercase(); + if s.contains("claude") { + return "anthropic".into(); + } + if s.contains("gemini") || s.contains("google") { + return "google".into(); + } + if s.contains("gpt") || s.contains("openai") { + return "openai".into(); + } + if s.contains("composer") || s.contains("cursor") || s.contains("kimi") { + return "cursor".into(); + } + if s.contains("deepseek") { + return "deepseek".into(); + } + "other".into() +} + +pub fn aggregate_by_provider(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let p = infer_provider(&row.model); + let e = m.entry(p).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(c); + } + out.chars().rev().collect() +} + +/// Comma-separated token counts (used by `list` and `usage-stats`). +pub fn format_token_count(n: u64) -> String { + fmt_num(n) +} + +fn sum_csv_rows(rows: &[CsvUsageRow]) -> RowAgg { + let mut a = RowAgg::default(); + for r in rows { + a.input_no_cache += r.input_no_cache; + a.input_cache_write += r.input_cache_write; + a.cache_read += r.cache_read; + a.output += r.output_tokens; + a.total_tokens += r.total_tokens; + a.cost_usd += r.cost_usd; + } + a +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorMtdTotals { + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, + pub since: String, + pub until: String, +} + +static MTD_CACHE: Mutex> = Mutex::new(None); +static CSV_RANGE_CACHE: Mutex> = Mutex::new(None); +const MTD_CACHE_TTL: Duration = Duration::from_secs(45 * 60); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageStatsRowJson { + pub key: String, + pub input: u64, + pub output: u64, + pub cache_write: u64, + pub cache_hit: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageStatsPayload { + pub since: String, + pub until: String, + pub group: String, + pub rows: Vec, + pub totals: UsageStatsRowJson, +} + +fn fetch_cursor_month_to_date_totals_uncached(plugin_id: &str) -> Option { + if resolve_state_db_for_plugin(plugin_id).is_none() { + return None; + } + let (since, until) = month_to_date_range_local().ok()?; + let (start_ms, end_ms) = to_epoch_range_ms(&since, &until).ok()?; + let csv_text = download_cursor_usage_csv_for_plugin(plugin_id, start_ms, end_ms).ok()?; + let rows = parse_usage_csv(&csv_text, &since, &until).ok()?; + let agg = sum_csv_rows(&rows); + Some(CursorMtdTotals { + total_tokens: agg.total_tokens, + input_tokens: agg.input_no_cache, + output_tokens: agg.output, + cost_usd: agg.cost_usd, + since, + until, + }) +} + +/// Month-to-date totals from Cursor's dashboard CSV (cached ~45m per month). +pub fn fetch_cursor_month_to_date_totals_for_plugin(plugin_id: &str) -> Option { + let (since, until) = month_to_date_range_local().ok()?; + let cache_key = format!("{plugin_id}:{since}-{until}"); + if let Ok(guard) = MTD_CACHE.lock() { + if let Some((key, at, totals)) = guard.as_ref() { + if key == &cache_key && at.elapsed() < MTD_CACHE_TTL { + return Some(totals.clone()); + } + } + } + let totals = fetch_cursor_month_to_date_totals_uncached(plugin_id)?; + if let Ok(mut guard) = MTD_CACHE.lock() { + *guard = Some((cache_key, Instant::now(), totals.clone())); + } + Some(totals) +} + +/// JSON for plugin host API (`cursorUsageExport.queryMtd`). +pub fn query_mtd_host_json(opts_json: &str) -> String { + let plugin_id = plugin_id_from_opts_json(opts_json); + match fetch_cursor_month_to_date_totals_for_plugin(&plugin_id) { + Some(data) => serde_json::json!({ "status": "ok", "data": data }).to_string(), + None => serde_json::json!({ + "status": "error", + "message": "Cursor MTD export unavailable (sign in via Cursor or try again later)" + }) + .to_string(), + } +} + +pub fn month_to_date_range_local() -> Result<(String, String)> { + let now = Local::now().date_naive(); + let first = + NaiveDate::from_ymd_opt(now.year(), now.month(), 1).context("invalid month start")?; + let since = first.format("%Y%m%d").to_string(); + let until = now.format("%Y%m%d").to_string(); + Ok((since, until)) +} + +pub fn resolve_date_range(since: Option<&str>, until: Option<&str>) -> Result<(String, String)> { + let default_until = Local::now().date_naive(); + let default_since = default_until - chrono::Duration::days(30); + let def_since = default_since.format("%Y%m%d").to_string(); + let def_until = default_until.format("%Y%m%d").to_string(); + + let since = since.map(|s| s.to_string()).unwrap_or(def_since); + let until = until.map(|s| s.to_string()).unwrap_or(def_until); + validate_yyyymmdd(&since)?; + validate_yyyymmdd(&until)?; + if since > until { + bail!("--since must be on or before --until"); + } + Ok((since, until)) +} + +fn validate_yyyymmdd(s: &str) -> Result<()> { + if s.len() != 8 || !s.chars().all(|c| c.is_ascii_digit()) { + bail!("Invalid date {s:?}: expected YYYYMMDD"); + } + let y: i32 = s[0..4].parse()?; + let m: u32 = s[4..6].parse()?; + let d: u32 = s[6..8].parse()?; + NaiveDate::from_ymd_opt(y, m, d).with_context(|| format!("invalid calendar date {s}"))?; + Ok(()) +} + +pub fn to_epoch_range_ms(since: &str, until: &str) -> Result<(i64, i64)> { + let y1: i32 = since[0..4].parse()?; + let m1: u32 = since[4..6].parse()?; + let d1: u32 = since[6..8].parse()?; + let y2: i32 = until[0..4].parse()?; + let m2: u32 = until[4..6].parse()?; + let d2: u32 = until[6..8].parse()?; + let nd1 = NaiveDate::from_ymd_opt(y1, m1, d1).unwrap(); + let nd2 = NaiveDate::from_ymd_opt(y2, m2, d2).unwrap(); + let start = Local + .from_local_datetime(&nd1.and_hms_opt(0, 0, 0).unwrap()) + .unwrap() + .timestamp_millis(); + let end = Local + .from_local_datetime(&nd2.and_hms_opt(23, 59, 59).unwrap()) + .unwrap() + .timestamp_millis(); + Ok((start, end)) +} + +fn plugin_id_from_opts_json(opts_json: &str) -> String { + serde_json::from_str::(opts_json) + .ok() + .and_then(|v| { + v.get("pluginId") + .or_else(|| v.get("baseProviderId")) + .and_then(|s| s.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "cursor".into()) +} + +fn resolve_state_db_for_plugin(plugin_id: &str) -> Option { + crate::cursor_paths::resolve_cursor_state_db_for_plugin_id(plugin_id) +} + +fn read_sqlite_value(db_path: &PathBuf, key: &str) -> Result> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let mut stmt = conn.prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1")?; + let mut rows = stmt.query_map([key], |row| row.get::<_, String>(0))?; + if let Some(r) = rows.next() { + let v = r?; + if v.trim().is_empty() { + return Ok(None); + } + return Ok(Some(v)); + } + Ok(None) +} + +fn write_sqlite_value(db_path: &PathBuf, key: &str, value: &str) -> Result<()> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + conn.execute( + "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?1, ?2)", + [key, value], + )?; + Ok(()) +} + +#[derive(Deserialize)] +struct JwtPayload { + sub: Option, + exp: Option, +} + +fn decode_jwt_payload(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let mut b64 = parts[1].replace('-', "+").replace('_', "/"); + let pad = (4 - b64.len() % 4) % 4; + b64.extend(std::iter::repeat('=').take(pad)); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&b64) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn user_id_from_sub(sub: &str) -> String { + let parts: Vec<&str> = sub.split('|').collect(); + if parts.len() >= 2 { + parts[parts.len() - 1].trim().to_string() + } else { + parts[0].trim().to_string() + } +} + +fn build_session_cookie(access_token: &str) -> Result { + let payload = decode_jwt_payload(access_token).context("invalid JWT access token")?; + let sub = payload.sub.as_deref().context("JWT missing sub")?; + let user_id = user_id_from_sub(sub); + let session = format!("{}%3A%3A{}", user_id, access_token); + Ok(format!("WorkosCursorSessionToken={session}")) +} + +fn needs_refresh(access_token: Option<&str>) -> bool { + let Some(t) = access_token else { + return true; + }; + let Some(p) = decode_jwt_payload(t) else { + return true; + }; + let Some(exp) = p.exp else { + return true; + }; + let now_ms = Utc::now().timestamp_millis(); + exp * 1000 <= now_ms + REFRESH_BUFFER_MS +} + +#[derive(Deserialize)] +struct RefreshBody { + access_token: Option, + should_logout: Option, +} + +fn refresh_access_token(refresh_token: &str, db_path: &PathBuf) -> Result> { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let resp = client + .post(REFRESH_URL) + .header("Content-Type", "application/json") + .json(&json!({ + "grant_type": "refresh_token", + "client_id": CLIENT_ID, + "refresh_token": refresh_token, + })) + .send()?; + + let status = resp.status(); + let body_text = resp.text()?; + if status == 400 || status == 401 { + let j: serde_json::Value = serde_json::from_str(&body_text).unwrap_or(json!({})); + if j.get("shouldLogout").and_then(|v| v.as_bool()) == Some(true) { + bail!("Cursor session expired. Open Cursor and sign in again."); + } + bail!("Token refresh failed ({status}). Open Cursor and sign in again."); + } + if !status.is_success() { + return Ok(None); + } + let body: RefreshBody = serde_json::from_str(&body_text).unwrap_or(RefreshBody { + access_token: None, + should_logout: None, + }); + if body.should_logout == Some(true) { + bail!("Cursor session expired. Open Cursor and sign in again."); + } + let Some(at) = body.access_token.filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let _ = write_sqlite_value(db_path, ACCESS_KEY, &at); + Ok(Some(at)) +} + +fn resolve_cursor_access_token(db_path: &PathBuf) -> Result { + let mut access = read_sqlite_value(db_path, ACCESS_KEY)?; + let refresh = read_sqlite_value(db_path, REFRESH_KEY)?; + + if access.is_none() && refresh.is_none() { + bail!( + "No Cursor auth in {}. Sign in via the Cursor app (tokens stored in state.vscdb).", + db_path.display() + ); + } + + if needs_refresh(access.as_deref()) { + if let Some(ref rt) = refresh { + if let Some(new_a) = refresh_access_token(rt, db_path)? { + access = Some(new_a); + } + } + } + + access.context("No usable Cursor access token. Open Cursor and sign in again.") +} + +pub fn download_cursor_usage_csv(start_ms: i64, end_ms: i64) -> Result { + download_cursor_usage_csv_for_plugin("cursor", start_ms, end_ms) +} + +pub fn download_cursor_usage_csv_for_plugin( + plugin_id: &str, + start_ms: i64, + end_ms: i64, +) -> Result { + let db_path = resolve_state_db_for_plugin(plugin_id).with_context(|| { + format!( + "Cursor state.vscdb not found for {plugin_id}. Install Cursor or Cursor Nightly and sign in." + ) + })?; + + let access = resolve_cursor_access_token(&db_path)?; + let cookie = build_session_cookie(&access)?; + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + + let url = format!( + "{EXPORT_URL}?startDate={}&endDate={}&strategy=tokens", + start_ms, end_ms + ); + let resp = client + .get(&url) + .header("Cookie", cookie) + .header("Accept", "text/csv") + .header( + "User-Agent", + "Mozilla/5.0 (compatible; openusage-cli usage-stats)", + ) + .send()?; + + if resp.status() == 401 || resp.status() == 403 { + bail!( + "Cursor export returned {} — auth may have expired. Open Cursor and retry.", + resp.status() + ); + } + if !resp.status().is_success() { + bail!("Cursor export failed: HTTP {}", resp.status()); + } + Ok(resp.text()?) +} + +fn fetch_csv_cached(plugin_id: &str, since: &str, until: &str) -> Result { + let cache_key = format!("{plugin_id}:{since}-{until}"); + if let Ok(guard) = CSV_RANGE_CACHE.lock() { + if let Some((key, at, text)) = guard.as_ref() { + if key == &cache_key && at.elapsed() < MTD_CACHE_TTL { + return Ok(text.clone()); + } + } + } + let (start_ms, end_ms) = to_epoch_range_ms(since, until)?; + let text = download_cursor_usage_csv_for_plugin(plugin_id, start_ms, end_ms)?; + if let Ok(mut guard) = CSV_RANGE_CACHE.lock() { + *guard = Some((cache_key, Instant::now(), text.clone())); + } + Ok(text) +} + +fn row_agg_to_json(key: String, a: &RowAgg) -> UsageStatsRowJson { + UsageStatsRowJson { + key, + input: a.input_no_cache, + output: a.output, + cache_write: a.input_cache_write, + cache_hit: a.cache_read, + total_tokens: a.total_tokens, + cost_usd: a.cost_usd, + } +} + +fn sorted_stats_rows(m: &HashMap) -> Vec { + let mut entries: Vec<_> = m + .iter() + .map(|(k, a)| row_agg_to_json(k.clone(), a)) + .collect(); + entries.sort_by(|a, b| { + b.cost_usd + .partial_cmp(&a.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + }); + entries +} + +/// Usage stats for a date range (same data as CLI `usage-stats`). +pub fn query_usage_stats( + plugin_id: &str, + since: Option<&str>, + until: Option<&str>, + group: &str, +) -> Result { + let (since, until) = if since.is_none() && until.is_none() { + month_to_date_range_local()? + } else { + resolve_date_range(since, until)? + }; + let group = group.to_lowercase(); + if group != "model" && group != "provider" { + bail!("group must be 'model' or 'provider'"); + } + + let csv_text = fetch_csv_cached(plugin_id, &since, &until)?; + let rows = parse_usage_csv(&csv_text, &since, &until)?; + let map = if group == "model" { + aggregate_by_model(&rows) + } else { + aggregate_by_provider(&rows) + }; + let stats_rows = sorted_stats_rows(&map); + let totals_agg = sum_csv_rows(&rows); + let totals = row_agg_to_json("total".into(), &totals_agg); + + Ok(UsageStatsPayload { + since, + until, + group, + rows: stats_rows, + totals, + }) +} + +/// JSON for plugin host API (`cursorUsageExport.queryStats`). +pub fn query_usage_stats_host_json(opts_json: &str) -> String { + let plugin_id = plugin_id_from_opts_json(opts_json); + let since = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("since").and_then(|s| s.as_str()).map(str::to_string)); + let until = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("until").and_then(|s| s.as_str()).map(str::to_string)); + let group = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("group").and_then(|s| s.as_str()).map(str::to_string)) + .unwrap_or_else(|| "model".into()); + + match query_usage_stats(&plugin_id, since.as_deref(), until.as_deref(), &group) { + Ok(data) => serde_json::json!({ "status": "ok", "data": data }).to_string(), + Err(e) => serde_json::json!({ + "status": "error", + "message": e.to_string() + }) + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tiny_csv() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01T12:00:00Z,Usage,gpt-5,No,100,200,300,400,1000,$1.50 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].model, "gpt-5"); + assert_eq!(rows[0].input_no_cache, 200); + assert_eq!(rows[0].input_cache_write, 100); + assert_eq!(rows[0].cache_read, 300); + assert_eq!(rows[0].output_tokens, 400); + assert!((rows[0].cost_usd - 1.5).abs() < 0.001); + } + + #[test] + fn aggregate_by_day_sums_cost() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01,Usage,gpt-5,No,100,200,300,400,1000,$1.50 +2026-03-01,Usage,claude-4,No,0,50,0,50,100,$0.25 +2026-03-02,Usage,gpt-5,No,0,10,0,10,20,$0.10 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + let by_day = aggregate_by_day(&rows); + assert_eq!(by_day.len(), 2); + let mar1 = by_day.get("2026-03-01").unwrap(); + assert!((mar1.cost_usd - 1.75).abs() < 0.001); + assert_eq!(mar1.total_tokens, 1100); + } + + #[test] + fn usage_stats_sorts_by_cost() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01T12:00:00Z,Usage,cheap,No,0,100,0,100,200,$0.50 +2026-03-01T12:00:00Z,Usage,pricey,No,0,100,0,100,200,$2.00 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + let by_model = aggregate_by_model(&rows); + let stats = sorted_stats_rows(&by_model); + assert_eq!(stats[0].key, "pricey"); + assert!(stats[0].cost_usd > stats[1].cost_usd); + } +} diff --git a/crates/openusage-core/src/cursor_usage_logs.rs b/crates/openusage-core/src/cursor_usage_logs.rs new file mode 100644 index 000000000..9417cb359 --- /dev/null +++ b/crates/openusage-core/src/cursor_usage_logs.rs @@ -0,0 +1,241 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Estimate daily Cursor activity from local agent transcript JSONL files (~/.cursor/projects). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use time::format_description::well_known::Iso8601; +use time::{Date, OffsetDateTime, PrimitiveDateTime, Time}; + +const CHARS_PER_TOKEN: u64 = 4; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorDailyUsage { + pub date: String, + pub total_tokens: u64, + #[serde(rename = "estimated")] + pub is_estimated: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorLogsStatus { + Ok, + NoData, +} + +pub fn cursor_agent_home() -> PathBuf { + if let Ok(v) = std::env::var("CURSOR_AGENT_HOME") { + return expand_tilde(&v); + } + dirs::home_dir() + .map(|h| h.join(".cursor")) + .unwrap_or_else(|| PathBuf::from(".cursor")) +} + +fn expand_tilde(path: &str) -> PathBuf { + let trimmed = path.trim(); + if trimmed == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(trimmed) +} + +fn parse_since_day(since: &str) -> Option { + let digits: String = since.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + let y: i32 = digits[0..4].parse().ok()?; + let m: u8 = digits[4..6].parse().ok()?; + let d: u8 = digits[6..8].parse().ok()?; + return Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok(); + } + Date::parse(since, &Iso8601::DEFAULT).ok() +} + +fn day_key_from_mtime(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?; + let dt = OffsetDateTime::from(modified); + Some(format!( + "{:04}-{:02}-{:02}", + dt.year(), + u8::from(dt.month()), + dt.day() + )) +} + +fn date_from_day_key(key: &str) -> Option { + if key.len() < 10 { + return None; + } + let y: i32 = key.get(0..4)?.parse().ok()?; + let m: u8 = key.get(5..7)?.parse().ok()?; + let d: u8 = key.get(8..10)?.parse().ok()?; + Date::from_calendar_date(y, time::Month::try_from(m).ok()?, d).ok() +} + +fn yyyymmdd(date: Date) -> String { + format!( + "{:04}{:02}{:02}", + date.year(), + u8::from(date.month()), + date.day() + ) +} + +fn estimate_tokens_in_jsonl(path: &Path) -> u64 { + let Ok(content) = std::fs::read_to_string(path) else { + return 0; + }; + let mut chars = 0u64; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(v) = serde_json::from_str::(trimmed) else { + continue; + }; + chars += extract_message_chars(&v); + } + chars / CHARS_PER_TOKEN +} + +fn extract_message_chars(v: &serde_json::Value) -> u64 { + let mut chars = 0u64; + if let Some(content) = v.pointer("/message/content").and_then(|c| c.as_array()) { + for item in content { + if let Some(text) = item.get("text").and_then(|t| t.as_str()) { + chars += text.chars().count() as u64; + } + } + } + chars +} + +fn collect_transcript_jsonl(projects_dir: &Path, out: &mut Vec) { + if !projects_dir.is_dir() { + return; + } + let Ok(entries) = std::fs::read_dir(projects_dir) else { + return; + }; + for entry in entries.flatten() { + let transcripts = entry.path().join("agent-transcripts"); + if transcripts.is_dir() { + walk_transcripts_dir(&transcripts, out); + } + } +} + +fn walk_transcripts_dir(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk_transcripts_dir(&path, out); + } else if path.extension().is_some_and(|e| e == "jsonl") { + out.push(path); + } + } +} + +/// Aggregate estimated tokens per calendar day from transcript files modified on/after `since` (YYYYMMDD or YYYY-MM-DD). +pub fn query_daily_since(since: &str) -> (CursorLogsStatus, Vec) { + let since_date = parse_since_day(since) + .unwrap_or_else(|| OffsetDateTime::now_utc().date() - time::Duration::days(30)); + let since_dt = PrimitiveDateTime::new(since_date, Time::MIDNIGHT).assume_utc(); + + let projects = cursor_agent_home().join("projects"); + let mut files = Vec::new(); + collect_transcript_jsonl(&projects, &mut files); + + let mut by_day: BTreeMap = BTreeMap::new(); + for path in files { + let Some(day_key) = day_key_from_mtime(&path) else { + continue; + }; + let Some(file_day) = date_from_day_key(&day_key) else { + continue; + }; + let file_dt = PrimitiveDateTime::new(file_day, Time::MIDNIGHT).assume_utc(); + if file_dt < since_dt { + continue; + } + let tokens = estimate_tokens_in_jsonl(&path); + if tokens == 0 { + continue; + } + *by_day.entry(day_key).or_insert(0) += tokens; + } + + if by_day.is_empty() { + return (CursorLogsStatus::NoData, vec![]); + } + + let daily = by_day + .into_iter() + .map(|(date, total_tokens)| CursorDailyUsage { + date, + total_tokens, + is_estimated: true, + }) + .collect(); + + (CursorLogsStatus::Ok, daily) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn estimates_tokens_from_transcript_jsonl() { + let dir = tempfile::tempdir().expect("tempdir"); + let projects = dir + .path() + .join("projects") + .join("demo") + .join("agent-transcripts"); + std::fs::create_dir_all(&projects).expect("mkdir"); + let jsonl = projects.join("sess.jsonl"); + let mut f = std::fs::File::create(&jsonl).expect("create"); + writeln!( + f, + r#"{{"role":"user","message":{{"content":[{{"type":"text","text":"{:040}"}}]}}}}"#, + "a".repeat(40) + ) + .expect("write"); + drop(f); + + let prev = std::env::var("CURSOR_AGENT_HOME").ok(); + // SAFETY: test-only env override; single-threaded test. + unsafe { + std::env::set_var("CURSOR_AGENT_HOME", dir.path()); + } + + let since = yyyymmdd(OffsetDateTime::now_utc().date() - time::Duration::days(1)); + let (status, daily) = query_daily_since(&since); + assert_eq!(status, CursorLogsStatus::Ok); + assert_eq!(daily.len(), 1); + assert_eq!(daily[0].total_tokens, 10); + + // SAFETY: test-only env restore; single-threaded test. + unsafe { + if let Some(p) = prev { + std::env::set_var("CURSOR_AGENT_HOME", p); + } else { + std::env::remove_var("CURSOR_AGENT_HOME"); + } + } + } +} diff --git a/crates/openusage-core/src/lib.rs b/crates/openusage-core/src/lib.rs new file mode 100644 index 000000000..b9981606d --- /dev/null +++ b/crates/openusage-core/src/lib.rs @@ -0,0 +1,18 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Shared plugin engine and path helpers for OpenUsage (Tauri app + CLI). + +pub mod claude_usage_scanner; +pub mod codex_usage_scanner; +pub mod cursor_paths; +pub mod cursor_usage_export; +pub mod cursor_usage_logs; +pub mod log_usage_types; +pub mod model_pricing; +pub mod paths; +pub mod plugin_engine; +pub mod provider_accounts; +mod provider_accounts_crypto; +pub mod proxy_config; +pub mod usage_daily; +pub mod usage_history; +pub mod usage_metrics; diff --git a/crates/openusage-core/src/log_usage_types.rs b/crates/openusage-core/src/log_usage_types.rs new file mode 100644 index 000000000..2e840ce9d --- /dev/null +++ b/crates/openusage-core/src/log_usage_types.rs @@ -0,0 +1,98 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Shared types for native Claude/Codex log scanners (ccusage-compatible daily output). + +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenBreakdown { + pub input: i32, + pub cache_write5m: i32, + pub cache_write1h: i32, + pub cache_read: i32, + pub output: i32, + #[serde(rename = "isFast")] + pub is_fast: bool, +} + +impl TokenBreakdown { + pub fn total_tokens(&self) -> i32 { + self.input + self.cache_write5m + self.cache_write1h + self.cache_read + self.output + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelDayUsage { + pub input_tokens: i32, + pub output_tokens: i32, + pub cache_creation_tokens: i32, + pub cache_read_tokens: i32, + pub total_tokens: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_cost: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyUsageRow { + pub date: String, + pub input_tokens: i32, + pub output_tokens: i32, + pub cache_creation_tokens: i32, + pub cache_read_tokens: i32, + pub total_tokens: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cost_usd: Option, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogScanStatus { + Ok, + NoData, +} + +pub fn local_day_key_from_offset(dt: &time::OffsetDateTime) -> String { + format!( + "{:04}-{:02}-{:02}", + dt.year(), + u8::from(dt.month()), + dt.day() + ) +} + +pub fn since_local_midnight(days_back: i32) -> time::OffsetDateTime { + let now = time::OffsetDateTime::now_utc(); + let date = now.date() - time::Duration::days(days_back as i64); + date.with_hms(0, 0, 0).expect("midnight").assume_utc() +} + +pub fn expand_tilde(path: &str) -> std::path::PathBuf { + let trimmed = path.trim(); + if trimmed == "~" { + return dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + std::path::PathBuf::from(trimmed) +} + +pub fn host_query_response(status: LogScanStatus, daily: Vec) -> String { + let status_str = match status { + LogScanStatus::Ok => "ok", + LogScanStatus::NoData => "no_data", + }; + serde_json::json!({ + "status": status_str, + "data": { "daily": daily } + }) + .to_string() +} diff --git a/crates/openusage-core/src/model_pricing.rs b/crates/openusage-core/src/model_pricing.rs new file mode 100644 index 000000000..185ddc423 --- /dev/null +++ b/crates/openusage-core/src/model_pricing.rs @@ -0,0 +1,378 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Bundled model pricing (supplement + LiteLLM compact snapshot). Ports upstream OpenUsage v0.7.3. + +use crate::log_usage_types::TokenBreakdown; +use regex_lite::Regex; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::OnceLock; + +const SUPPLEMENT_JSON: &str = include_str!("../data/pricing_supplement.json"); +const LITELLM_JSON: &str = include_str!("../data/pricing_litellm_snapshot.json"); + +#[derive(Debug, Clone)] +pub struct ModelRates { + pub input_per_million: f64, + pub output_per_million: f64, + pub cache_write_per_million: f64, + pub cache_read_per_million: f64, + pub input_above_200k: Option, + pub output_above_200k: Option, + pub cache_write_above_200k: Option, + pub cache_read_above_200k: Option, + pub fast_multiplier: f64, +} + +impl ModelRates { + pub fn scaled(&self, factor: f64) -> Self { + Self { + input_per_million: self.input_per_million * factor, + output_per_million: self.output_per_million * factor, + cache_write_per_million: self.cache_write_per_million * factor, + cache_read_per_million: self.cache_read_per_million * factor, + input_above_200k: self.input_above_200k.map(|v| v * factor), + output_above_200k: self.output_above_200k.map(|v| v * factor), + cache_write_above_200k: self.cache_write_above_200k.map(|v| v * factor), + cache_read_above_200k: self.cache_read_above_200k.map(|v| v * factor), + fast_multiplier: 1.0, + } + } + + pub fn cost_dollars(&self, tokens: &TokenBreakdown) -> f64 { + const CACHE_WRITE_1H_MULT: f64 = 2.0; + let mult = if tokens.is_fast { + self.fast_multiplier + } else { + 1.0 + }; + let cost = tiered_cost(tokens.input, self.input_per_million, self.input_above_200k) + + tiered_cost( + tokens.output, + self.output_per_million, + self.output_above_200k, + ) + + tiered_cost( + tokens.cache_write5m, + self.cache_write_per_million, + self.cache_write_above_200k, + ) + + tiered_cost( + tokens.cache_write1h, + self.input_per_million * CACHE_WRITE_1H_MULT, + self.input_above_200k.map(|v| v * CACHE_WRITE_1H_MULT), + ) + + tiered_cost( + tokens.cache_read, + self.cache_read_per_million, + self.cache_read_above_200k, + ); + cost * mult + } +} + +fn tiered_cost(tokens: i32, base: f64, above: Option) -> f64 { + if tokens <= 0 { + return 0.0; + } + let threshold = 200_000; + if let Some(above_rate) = above { + if tokens > threshold { + return (f64::from(threshold) * base + f64::from(tokens - threshold) * above_rate) + / 1_000_000.0; + } + } + f64::from(tokens) * base / 1_000_000.0 +} + +struct AliasRule { + pattern: Regex, + canonical: String, +} + +struct PricingSupplement { + pricing: HashMap, + fast_multipliers: HashMap, + alias_rules: Vec, +} + +#[derive(Deserialize)] +struct SupplementFile { + pricing: HashMap, + fast_multipliers: HashMap, + alias_rules: Vec, +} + +#[derive(Deserialize)] +struct SupplementRates { + input_per_million: f64, + output_per_million: f64, + #[serde(default)] + cache_write_per_million: Option, + #[serde(default)] + cache_read_per_million: Option, +} + +#[derive(Deserialize)] +struct AliasRuleFile { + pattern: String, + canonical: String, +} + +#[derive(Deserialize)] +struct CompactCatalog { + models: HashMap, +} + +#[derive(Deserialize)] +struct CompactModel { + i: f64, + o: f64, + cw: f64, + cr: f64, + #[serde(default)] + ia: Option, + #[serde(default)] + oa: Option, + #[serde(default)] + cwa: Option, + #[serde(default)] + cra: Option, + #[serde(default)] + fast: Option, +} + +pub struct ModelPricing { + supplement: PricingSupplement, + primary: HashMap, +} + +static DEFAULT_PRICING: OnceLock = OnceLock::new(); + +pub fn default_pricing() -> &'static ModelPricing { + DEFAULT_PRICING.get_or_init(ModelPricing::from_bundled) +} + +impl ModelPricing { + pub fn from_bundled() -> Self { + let supplement = load_supplement(SUPPLEMENT_JSON); + let primary = load_compact_catalog(LITELLM_JSON); + Self { + supplement, + primary, + } + } + + pub fn can_price(&self, model: &str) -> bool { + self.resolve(model).is_some() + } + + pub fn estimated_cost_dollars(&self, model: &str, tokens: &TokenBreakdown) -> Option { + self.resolve(model).map(|r| r.cost_dollars(tokens)) + } + + pub fn resolve(&self, model: &str) -> Option { + if let Some(canonical) = self.supplement.canonical_name(model) { + if canonical != model { + if let Some(r) = self.lookup(&canonical) { + return Some(r); + } + } + } + self.lookup(model) + } + + fn lookup(&self, name: &str) -> Option { + if let Some(r) = self.supplement.pricing.get(name) { + return Some(r.clone()); + } + if let Some(r) = self.primary.get(name) { + return Some(r.clone()); + } + if let Some(r) = self.fast_variant(name) { + return Some(r); + } + self.fuzzy_primary(name) + } + + fn fast_variant(&self, name: &str) -> Option { + const SUFFIX: &str = "-fast"; + if !name.ends_with(SUFFIX) { + return None; + } + let base = &name[..name.len() - SUFFIX.len()]; + if base.is_empty() { + return None; + } + let (key, rates) = self.base_entry(base)?; + let multiplier = if rates.fast_multiplier != 1.0 { + rates.fast_multiplier + } else { + self.supplement + .fast_multiplier(&key) + .or_else(|| self.supplement.fast_multiplier(base))? + }; + Some(rates.scaled(multiplier)) + } + + fn base_entry(&self, base: &str) -> Option<(String, ModelRates)> { + if let Some(r) = self.supplement.pricing.get(base) { + return Some((base.to_string(), r.clone())); + } + self.primary + .get(base) + .map(|r| (base.to_string(), r.clone())) + .or_else(|| self.fuzzy_primary(base).map(|r| (base.to_string(), r))) + } + + fn fuzzy_primary(&self, model: &str) -> Option { + let normalized = normalized_key(model); + let mut best: Option<(&str, &ModelRates)> = None; + for (key, rates) in &self.primary { + if key_matches(key, model, &normalized) { + best = match best { + Some((bk, _)) + if key.len() > bk.len() || (key.len() == bk.len() && key.as_str() < bk) => + { + Some((key.as_str(), rates)) + } + Some(current) => Some(current), + None => Some((key.as_str(), rates)), + }; + } + } + best.map(|(_, r)| r.clone()) + } +} + +impl PricingSupplement { + fn canonical_name(&self, model: &str) -> Option { + for rule in &self.alias_rules { + if rule.pattern.is_match(model) { + return Some(rule.canonical.clone()); + } + } + None + } + + fn fast_multiplier(&self, model: &str) -> Option { + if let Some(v) = self.fast_multipliers.get(model) { + return Some(*v); + } + let normalized = normalized_key(model); + for part in normalized.split(['/', ':']) { + for (base, mult) in &self.fast_multipliers { + if matches_model_suffix(part, &normalized_key(base)) { + return Some(*mult); + } + } + } + None + } +} + +fn load_supplement(json: &str) -> PricingSupplement { + let file: SupplementFile = serde_json::from_str(json).unwrap_or(SupplementFile { + pricing: HashMap::new(), + fast_multipliers: HashMap::new(), + alias_rules: vec![], + }); + let mut pricing = HashMap::new(); + for (model, entry) in file.pricing { + pricing.insert( + model, + ModelRates { + input_per_million: entry.input_per_million, + output_per_million: entry.output_per_million, + cache_write_per_million: entry + .cache_write_per_million + .unwrap_or(entry.input_per_million), + cache_read_per_million: entry + .cache_read_per_million + .unwrap_or(entry.input_per_million * 0.1), + input_above_200k: None, + output_above_200k: None, + cache_write_above_200k: None, + cache_read_above_200k: None, + fast_multiplier: 1.0, + }, + ); + } + let alias_rules = file + .alias_rules + .into_iter() + .filter_map(|rule| { + Regex::new(&rule.pattern).ok().map(|pattern| AliasRule { + pattern, + canonical: rule.canonical, + }) + }) + .collect(); + PricingSupplement { + pricing, + fast_multipliers: file.fast_multipliers, + alias_rules, + } +} + +fn load_compact_catalog(json: &str) -> HashMap { + let file: CompactCatalog = serde_json::from_str(json).unwrap_or(CompactCatalog { + models: HashMap::new(), + }); + file.models + .into_iter() + .map(|(key, m)| { + ( + key, + ModelRates { + input_per_million: m.i, + output_per_million: m.o, + cache_write_per_million: m.cw, + cache_read_per_million: m.cr, + input_above_200k: m.ia, + output_above_200k: m.oa, + cache_write_above_200k: m.cwa, + cache_read_above_200k: m.cra, + fast_multiplier: m.fast.unwrap_or(1.0), + }, + ) + }) + .collect() +} + +fn normalized_key(model: &str) -> String { + model.to_ascii_lowercase() +} + +fn key_matches(candidate: &str, model: &str, normalized_model: &str) -> bool { + let nc = normalized_key(candidate); + normalized_model.contains(&nc) + || normalized_key(model).contains(&nc) + || model.contains(candidate) +} + +fn matches_model_suffix(part: &str, base: &str) -> bool { + part.rfind(base) + .map(|idx| { + let suffix = &part[idx + base.len()..]; + suffix.is_empty() || suffix.starts_with('-') + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_pricing_resolves_claude_sonnet() { + let p = ModelPricing::from_bundled(); + assert!(p.can_price("claude-sonnet-4-20250514")); + } + + #[test] + fn opus_fast_override_from_supplement() { + let p = ModelPricing::from_bundled(); + let rates = p.resolve("claude-opus-4-7-fast").expect("opus fast"); + assert!((rates.input_per_million - 30.0).abs() < 0.01); + } +} diff --git a/crates/openusage-core/src/paths.rs b/crates/openusage-core/src/paths.rs new file mode 100644 index 000000000..07fd0d1c1 --- /dev/null +++ b/crates/openusage-core/src/paths.rs @@ -0,0 +1,210 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Resolve app data and resource dirs to match the Tauri app (`com.sunstory.openusage`). + +use std::path::{Path, PathBuf}; + +/// Identifier folder segment (matches `tauri.conf.json` identifier). +const APP_ID: &str = "com.sunstory.openusage"; + +/// App data directory (plugins, settings, etc.) — same family as Tauri `app_data_dir`. +pub fn app_data_dir() -> Option { + dirs::data_dir().map(|p| p.join(APP_ID)) +} + +/// Context collected when no bundled plugin tree was found (for user-facing hints). +#[derive(Debug, Clone, Default)] +pub struct ResourceDirProbeContext { + /// Canonical parent directory of the running executable, if known. + pub current_exe_parent: Option, + /// Current working directory when probing ran. + pub current_dir: Option, +} + +/// Outcome of locating bundled plugin resources for the CLI. +#[derive(Debug, Clone)] +pub enum ResourceDirResolution { + /// Resource root to pass to [`crate::plugin_engine::initialize_plugins`]. + /// + /// - If set via `OPENUSAGE_RESOURCES`, the path is used even when nothing exists yet + /// (caller may get empty plugins until the path is fixed). + /// - Otherwise, this root is one where `bundled_plugins/` or `resources/bundled_plugins/` + /// was found to exist. + Resolved(PathBuf), + /// No bundled plugin directory was found in any standard location. The CLI must **not** + /// fall back to the current working directory (`.`). + NotFound(ResourceDirProbeContext), +} + +/// Resolved paths for the standalone CLI. +#[derive(Debug, Clone)] +pub struct CliPaths { + pub app_data: PathBuf, + /// `None` when no bundled plugin location was discovered. + pub resource_dir: Option, + /// Same as [`ResourceDirResolution`]; use for diagnostics when `resource_dir` is `None` + /// or plugins are still empty after init. + pub resource_resolution: ResourceDirResolution, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathsError { + /// `dirs::data_dir()` returned `None` (rare headless / misconfigured systems). + NoAppDataDir, +} + +fn bundled_exists_under_resource_root(root: &Path) -> bool { + root.join("bundled_plugins").is_dir() || root.join("resources/bundled_plugins").is_dir() +} + +/// Returns the resource root directory when `bundled_plugins` lives beside the executable +/// (portable tarball / local install layout). +fn bundled_root_beside_exe_dir(exe_dir: &Path) -> Option { + let beside = exe_dir.join("resources"); + if beside.join("bundled_plugins").is_dir() || beside.join("resources/bundled_plugins").is_dir() + { + return Some(beside); + } + if exe_dir.join("bundled_plugins").is_dir() { + return Some(exe_dir.to_path_buf()); + } + None +} + +/// Locate bundled plugin resources for the CLI without falling back to `.`. +/// +/// Resolution order matches the previous `resource_dir()` behavior, except the silent `.` +/// fallback is removed. +/// +/// - If `OPENUSAGE_RESOURCES` is set, it is used as the resource root. +/// - Otherwise: `../Resources` next to the executable (macOS app bundle), +/// or Homebrew-style `share/openusage` on macOS, +/// or `/usr/share/openusage` (Linux .deb-style install), or `resources/` next to the **real** +/// executable (portable tarball / `install.sh` layout; `current_exe` is canonicalized so +/// `~/.local/bin/...` symlinks resolve like Windows/Linux portable), +/// or the OpenUsage repo’s `src-tauri/resources` when the current working directory is the repo root. +pub fn resolve_resource_dir() -> ResourceDirResolution { + if let Ok(p) = std::env::var("OPENUSAGE_RESOURCES") { + return ResourceDirResolution::Resolved(PathBuf::from(p)); + } + + if let Ok(exe) = std::env::current_exe() { + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + if let Some(exe_dir) = exe.parent() { + // Tauri app bundle: Contents/MacOS/exe -> Contents/Resources + #[cfg(target_os = "macos")] + { + if exe_dir.ends_with("MacOS") { + let resources = exe_dir + .parent() + .map(|p| p.join("Resources")) + .unwrap_or_else(|| exe_dir.to_path_buf()); + if bundled_exists_under_resource_root(&resources) { + return ResourceDirResolution::Resolved(resources); + } + } + + // Standalone CLI (Homebrew / /usr/local): same layout as Linux installs + for share in [ + PathBuf::from("/opt/homebrew/share/openusage"), + PathBuf::from("/usr/local/share/openusage"), + ] { + if bundled_exists_under_resource_root(&share) { + return ResourceDirResolution::Resolved(share); + } + } + } + + // Linux: /usr/bin/openusage -> /usr/share/openusage + #[cfg(target_os = "linux")] + { + let share = PathBuf::from("/usr/share/openusage"); + if bundled_exists_under_resource_root(&share) { + return ResourceDirResolution::Resolved(share); + } + } + + // Next to executable (portable / dev) — Windows, Linux, macOS + if let Some(root) = bundled_root_beside_exe_dir(exe_dir) { + return ResourceDirResolution::Resolved(root); + } + } + } + + // Monorepo dev: run CLI from repo root with `src-tauri/resources/bundled_plugins` + if let Ok(cwd) = std::env::current_dir() { + let r = cwd.join("src-tauri/resources"); + if bundled_exists_under_resource_root(&r) { + return ResourceDirResolution::Resolved(r); + } + } + + let mut ctx = ResourceDirProbeContext::default(); + if let Ok(exe) = std::env::current_exe() { + let exe = std::fs::canonicalize(&exe).unwrap_or(exe); + ctx.current_exe_parent = exe.parent().map(|p| p.to_path_buf()); + } + ctx.current_dir = std::env::current_dir().ok(); + + ResourceDirResolution::NotFound(ctx) +} + +/// Resolve app data + resource paths for the CLI. +pub fn resolve_cli_paths() -> Result { + let app_data = app_data_dir().ok_or(PathsError::NoAppDataDir)?; + let resource_resolution = resolve_resource_dir(); + let resource_dir = match &resource_resolution { + ResourceDirResolution::Resolved(p) => Some(p.clone()), + ResourceDirResolution::NotFound(_) => None, + }; + Ok(CliPaths { + app_data, + resource_dir, + resource_resolution, + }) +} + +/// User-facing hint when no bundled plugin root was discovered (macOS terminal / bare `cargo install`, etc.). +pub fn missing_bundled_plugins_hint(ctx: &ResourceDirProbeContext) -> String { + let exe_note = ctx + .current_exe_parent + .as_ref() + .map(|p| format!("Executable directory (canonicalized): {}\n", p.display())) + .unwrap_or_default(); + let cwd_note = ctx + .current_dir + .as_ref() + .map(|p| format!("Current directory: {}\n", p.display())) + .unwrap_or_default(); + format!( + "Bundled providers were not found under any known install location.\n\ +{exe_note}\ +{cwd_note}\ +Fix: install the official portable CLI bundle (binary + resources/bundled_plugins together), e.g.\n\ + curl -fsSL …/scripts/install.sh | INSTALL_MODE=cli bash\n\ +Or set OPENUSAGE_RESOURCES to a folder that contains bundled_plugins/ or resources/bundled_plugins/ \ +(e.g. OpenUsage.app/Contents/Resources, ~/.local/lib/openusage/resources, or your clone’s src-tauri/resources).\n\ +If you use a bare cargo install, you must supply bundled plugins via OPENUSAGE_RESOURCES or run from a repo checkout with plugins populated.\n\ +Launching the desktop app once can copy bundled plugins into app data if the GUI build includes them." + ) +} + +/// Hint when a resource root was chosen but no plugins loaded (wrong env path, empty bundle, etc.). +pub fn empty_plugins_with_resource_root_hint(resource_root: &Path) -> String { + format!( + "No bundled_plugins found under the resolved resource root ({}).\n\ +Expected {} or {}.\n\ +Fix: re-install the official CLI tarball (keep binary and resources/ together), or set OPENUSAGE_RESOURCES correctly, \ +or launch the desktop app once so it seeds plugins into app data.", + resource_root.display(), + resource_root.join("bundled_plugins").display(), + resource_root.join("resources/bundled_plugins").display(), + ) +} + +/// Message to show when the CLI finished plugin discovery but found no providers. +pub fn plugins_empty_diagnostic(resolution: &ResourceDirResolution) -> String { + match resolution { + ResourceDirResolution::Resolved(p) => empty_plugins_with_resource_root_hint(p), + ResourceDirResolution::NotFound(ctx) => missing_bundled_plugins_hint(ctx), + } +} diff --git a/src-tauri/src/plugin_engine/host_api.rs b/crates/openusage-core/src/plugin_engine/host_api.rs similarity index 62% rename from src-tauri/src/plugin_engine/host_api.rs rename to crates/openusage-core/src/plugin_engine/host_api.rs index 3f4dff60e..6a1b75b36 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/crates/openusage-core/src/plugin_engine/host_api.rs @@ -1,36 +1,27 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +use crate::provider_accounts::{self, ProviderAccountContext, ProviderCredential}; use aes_gcm::{ AesGcm, Nonce, aead::{Aead, KeyInit, OsRng, generic_array::typenum::U16, rand_core::RngCore}, aes::Aes256, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use rquickjs::{Ctx, Exception, Function, Object}; +use rquickjs::{Ctx, Exception, Function, Object, function::Rest}; +use rusqlite::{Connection, OpenFlags}; +use serde_json::{Map, Value as JsonValue}; use sha2::{Digest, Sha256}; +#[cfg(test)] +use std::collections::VecDeque; use std::collections::{HashMap, HashSet}; + use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; +#[cfg(unix)] +use users::os::unix::UserExt; -const WHITELISTED_ENV_VARS: [&str; 16] = [ - "CODEX_HOME", - "CLAUDE_CONFIG_DIR", - "CLAUDE_CODE_OAUTH_TOKEN", - "USER_TYPE", - "USE_STAGING_OAUTH", - "USE_LOCAL_OAUTH", - "CLAUDE_CODE_CUSTOM_OAUTH_URL", - "CLAUDE_CODE_OAUTH_CLIENT_ID", - "CLAUDE_LOCAL_OAUTH_API_BASE", - "ZAI_API_KEY", - "GLM_API_KEY", - "MINIMAX_API_KEY", - "MINIMAX_API_TOKEN", - "MINIMAX_CN_API_KEY", - "SYNTHETIC_API_KEY", - "PI_CODING_AGENT_DIR", -]; const MIN_BLOCKING_TIMEOUT: Duration = Duration::from_millis(1); #[derive(Clone, Copy, Debug)] @@ -39,7 +30,6 @@ pub(crate) struct ProbeDeadline { } impl ProbeDeadline { - #[cfg(test)] pub(crate) fn none() -> Self { Self { expires_at: None } } @@ -75,9 +65,37 @@ fn log_probe_deadline_skip(plugin_id: &str, operation: &str) { ); } -fn probe_timeout_error<'js>(ctx: &Ctx<'js>) -> rquickjs::Error { - Exception::throw_message(ctx, "probe timed out") -} +const WHITELISTED_ENV_VARS: [&str; 26] = [ + "CODEX_HOME", + "CLAUDE_CONFIG_DIR", + "CLAUDE_CODE_OAUTH_TOKEN", + "CROFAI_API_KEY", + "USER_TYPE", + "USE_STAGING_OAUTH", + "USE_LOCAL_OAUTH", + "CLAUDE_CODE_CUSTOM_OAUTH_URL", + "CLAUDE_CODE_OAUTH_CLIENT_ID", + "CLAUDE_LOCAL_OAUTH_API_BASE", + "ZAI_API_KEY", + "GLM_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_API_TOKEN", + "MINIMAX_CN_API_KEY", + "SYNTHETIC_API_KEY", + "PI_CODING_AGENT_DIR", + "COMMAND_CODE_API_KEY", + "DEEPSEEK_API_KEY", + "DEEPSEEK_INITIAL_BALANCE", + "OLLAMA_API_KEY", + "OLLAMA_HOST", + "OLLAMA_SESSION_COOKIE", + "OLLAMA_COOKIE", + "NEURALWATT_API_KEY", + "FIREWORKS_API_KEY", +]; + +const FIRECTL_TIMEOUT_SECS: u64 = 15; +const FIRECTL_POLL_INTERVAL_MS: u64 = 50; fn last_non_empty_trimmed_line(text: &str) -> Option { text.lines() @@ -139,76 +157,6 @@ fn read_env_value_via_command(program: &str, args: &[&str]) -> Option { sanitize_env_value(&stdout) } -fn current_macos_keychain_account_from_user_env(user_env: Option) -> String { - user_env - .and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) - .or_else(|| read_env_value_via_command("id", &["-un"])) - .unwrap_or_else(|| "openusage-user".to_string()) -} - -fn current_macos_keychain_account() -> String { - current_macos_keychain_account_from_user_env(read_env_from_process("USER")) -} - -#[cfg(any(target_os = "macos", test))] -fn keychain_find_generic_password_args(service: &str) -> Vec { - vec![ - OsString::from("find-generic-password"), - OsString::from("-s"), - OsString::from(service), - OsString::from("-w"), - ] -} - -#[cfg(any(target_os = "macos", test))] -fn keychain_find_generic_password_args_for_account(service: &str, account: &str) -> Vec { - vec![ - OsString::from("find-generic-password"), - OsString::from("-a"), - OsString::from(account), - OsString::from("-s"), - OsString::from(service), - OsString::from("-w"), - ] -} - -#[cfg(any(target_os = "macos", test))] -fn keychain_add_generic_password_args(service: &str, value: &str) -> Vec { - vec![ - OsString::from("add-generic-password"), - OsString::from("-U"), - OsString::from("-s"), - OsString::from(service), - OsString::from("-w"), - OsString::from(value), - ] -} - -#[cfg(any(target_os = "macos", test))] -fn keychain_add_generic_password_args_for_account( - service: &str, - account: &str, - value: &str, -) -> Vec { - vec![ - OsString::from("add-generic-password"), - OsString::from("-U"), - OsString::from("-a"), - OsString::from(account), - OsString::from("-s"), - OsString::from(service), - OsString::from("-w"), - OsString::from(value), - ] -} - fn terminal_env_cache() -> &'static Mutex>> { static CACHE: OnceLock>>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) @@ -288,6 +236,72 @@ fn resolve_env_value(name: &str) -> Option { resolved } +fn current_macos_keychain_account_from_user_env(user_env: Option) -> String { + user_env + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + .or_else(|| read_env_value_via_command("id", &["-un"])) + .unwrap_or_else(|| "openusage-user".to_string()) +} + +fn current_macos_keychain_account() -> String { + current_macos_keychain_account_from_user_env(read_env_from_process("USER")) +} + +fn keychain_find_generic_password_args(service: &str) -> Vec { + vec![ + OsString::from("find-generic-password"), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + ] +} + +fn keychain_find_generic_password_args_for_account(service: &str, account: &str) -> Vec { + vec![ + OsString::from("find-generic-password"), + OsString::from("-a"), + OsString::from(account), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + ] +} + +fn keychain_add_generic_password_args(service: &str, value: &str) -> Vec { + vec![ + OsString::from("add-generic-password"), + OsString::from("-U"), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + OsString::from(value), + ] +} + +fn keychain_add_generic_password_args_for_account( + service: &str, + account: &str, + value: &str, +) -> Vec { + vec![ + OsString::from("add-generic-password"), + OsString::from("-U"), + OsString::from("-a"), + OsString::from(account), + OsString::from("-s"), + OsString::from(service), + OsString::from("-w"), + OsString::from(value), + ] +} + /// Redact sensitive value to first4...last4 format (UTF-8 safe) fn redact_value(value: &str) -> String { let chars: Vec = value.chars().collect(); @@ -309,6 +323,11 @@ fn redact_value(value: &str) -> String { /// Redact sensitive query parameters in URL fn redact_url(url: &str) -> String { + static ACCOUNT_PATH_PATTERN: OnceLock = OnceLock::new(); + let account_path_pattern = ACCOUNT_PATH_PATTERN.get_or_init(|| { + regex_lite::Regex::new(r"(?P/accounts/)(?P[^/?#]+)") + .expect("valid account path regex") + }); let sensitive_params = [ "key", "api_key", @@ -332,6 +351,12 @@ fn redact_url(url: &str) -> String { "login", ]; + let url = account_path_pattern + .replace_all(url, |caps: ®ex_lite::Captures| { + format!("{}{}", &caps["prefix"], redact_value(&caps["value"])) + }) + .to_string(); + if let Some(query_start) = url.find('?') { let (base, query) = url.split_at(query_start + 1); let redacted_params: Vec = query @@ -354,7 +379,7 @@ fn redact_url(url: &str) -> String { .collect(); format!("{}{}", base, redacted_params.join("&")) } else { - url.to_string() + url } } @@ -382,6 +407,14 @@ fn redact_body(body: &str) -> String { }) .to_string(); + if let Ok(devin_session_re) = regex_lite::Regex::new(r#"devin-session-token\$[^\s"',}\]]+"#) { + result = devin_session_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } + // Redact JSON values for sensitive keys let sensitive_keys = [ "name", @@ -409,6 +442,10 @@ fn redact_body(body: &str) -> String { "accountId", "team_id", "teamId", + "org_id", + "orgId", + "account_display_name", + "accountDisplayName", "payment_id", "paymentId", "profile_arn", @@ -430,6 +467,17 @@ fn redact_body(body: &str) -> String { } } + // Redact email addresses even in HTML/text responses. + if let Ok(email_re) = + regex_lite::Regex::new(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}") + { + result = email_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } + if let Ok(path_re) = regex_lite::Regex::new(r#"(/(?:Users|home|opt|private|var|tmp|Applications)/[^\s"')]+)"#) { @@ -439,9 +487,18 @@ fn redact_body(body: &str) -> String { result } -/// Lightweight redaction for log messages. -pub(crate) fn redact_log_message(msg: &str) -> String { +/// Lightweight redaction for plugin log messages (JWT + API key patterns only). +pub fn redact_log_message(msg: &str) -> String { let mut result = msg.to_string(); + if let Ok(email_re) = + regex_lite::Regex::new(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}") + { + result = email_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") { result = jwt_re @@ -457,6 +514,13 @@ pub(crate) fn redact_log_message(msg: &str) -> String { }) .to_string(); } + if let Ok(devin_session_re) = regex_lite::Regex::new(r#"devin-session-token\$[^\s"',}\]]+"#) { + result = devin_session_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + } if let Ok(account_re) = regex_lite::Regex::new(r#"(account=)([^,\s]+)"#) { result = account_re .replace_all(&result, |caps: ®ex_lite::Captures| { @@ -563,16 +627,19 @@ fn encrypt_aes_256_gcm_envelope(plaintext: &str, key_b64: &str) -> Result( +pub fn inject_host_api<'js>( ctx: &Ctx<'js>, - plugin_id: &str, + base_plugin_id: &str, + instance_id: &str, + account: Option<&ProviderAccountContext>, app_data_dir: &PathBuf, app_version: &str, ) -> rquickjs::Result<()> { inject_host_api_with_deadline( ctx, - plugin_id, + base_plugin_id, + instance_id, + account, app_data_dir, app_version, ProbeDeadline::none(), @@ -581,7 +648,9 @@ pub(crate) fn inject_host_api<'js>( pub(crate) fn inject_host_api_with_deadline<'js>( ctx: &Ctx<'js>, - plugin_id: &str, + base_plugin_id: &str, + instance_id: &str, + account: Option<&ProviderAccountContext>, app_data_dir: &PathBuf, app_version: &str, deadline: ProbeDeadline, @@ -595,11 +664,11 @@ pub(crate) fn inject_host_api_with_deadline<'js>( app_obj.set("version", app_version)?; app_obj.set("platform", std::env::consts::OS)?; app_obj.set("appDataDir", app_data_dir.to_string_lossy().to_string())?; - let plugin_data_dir = app_data_dir.join("plugins_data").join(plugin_id); + let plugin_data_dir = app_data_dir.join("plugins_data").join(instance_id); if let Err(err) = std::fs::create_dir_all(&plugin_data_dir) { log::warn!( "[plugin:{}] failed to create plugin data dir: {}", - plugin_id, + instance_id, err ); } @@ -609,16 +678,35 @@ pub(crate) fn inject_host_api_with_deadline<'js>( )?; probe_ctx.set("app", app_obj)?; + let account_obj = Object::new(ctx.clone())?; + account_obj.set("instanceId", instance_id)?; + account_obj.set("baseProviderId", base_plugin_id)?; + account_obj.set("label", account.map(|a| a.label.as_str()).unwrap_or(""))?; + probe_ctx.set("account", account_obj)?; + let host = Object::new(ctx.clone())?; - inject_log(ctx, &host, plugin_id)?; + let host_account_obj = Object::new(ctx.clone())?; + host_account_obj.set("instanceId", instance_id)?; + host_account_obj.set("baseProviderId", base_plugin_id)?; + host_account_obj.set("label", account.map(|a| a.label.as_str()).unwrap_or(""))?; + host.set("account", host_account_obj)?; + inject_log(ctx, &host, instance_id)?; inject_fs(ctx, &host)?; + inject_cursor_paths(ctx, &host, base_plugin_id)?; inject_crypto(ctx, &host)?; - inject_env(ctx, &host, plugin_id)?; - inject_http(ctx, &host, plugin_id, deadline)?; - inject_keychain(ctx, &host, plugin_id)?; + inject_env(ctx, &host, base_plugin_id)?; + inject_http(ctx, &host, instance_id, deadline)?; + inject_credentials(ctx, &host, account)?; + inject_keychain(ctx, &host, instance_id)?; inject_sqlite(ctx, &host)?; - inject_ls(ctx, &host, plugin_id)?; - inject_ccusage(ctx, &host, plugin_id, deadline)?; + inject_ls(ctx, &host, base_plugin_id)?; + inject_ccusage(ctx, &host, base_plugin_id, deadline)?; + inject_usage_daily(ctx, &host, instance_id, app_data_dir)?; + inject_cursor_logs(ctx, &host, base_plugin_id, deadline)?; + inject_claude_logs(ctx, &host, deadline)?; + inject_codex_logs(ctx, &host, deadline)?; + inject_cursor_usage_export(ctx, &host, deadline)?; + inject_fireworks(ctx, &host, base_plugin_id)?; probe_ctx.set("host", host)?; globals.set("__openusage_ctx", probe_ctx)?; @@ -657,46 +745,269 @@ fn inject_log<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquic Ok(()) } -fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { - let fs_obj = Object::new(ctx.clone())?; +fn home_dir() -> Option { + dirs::home_dir() + .or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)) + .or_else(|| { + #[cfg(windows)] + { + std::env::var_os("USERPROFILE").map(std::path::PathBuf::from) + } + #[cfg(all(unix, not(windows)))] + { + let uid = users::get_current_uid(); + users::get_user_by_uid(uid).map(|user| user.home_dir().to_path_buf()) + } + #[cfg(not(any(windows, unix)))] + { + None + } + }) +} - fs_obj.set( - "exists", - Function::new(ctx.clone(), move |path: String| -> bool { - let expanded = expand_path(&path); - std::path::Path::new(&expanded).exists() - })?, - )?; +/// Tilde paths for VS Code–style app data under Linux XDG, macOS Library, and Windows Roaming. +fn app_support_path_candidates(relative: &str) -> Vec { + let rel = relative.trim_start_matches(['/', '\\']); + vec![ + format!("~/.config/{rel}"), + format!("~/Library/Application Support/{rel}"), + format!("~/AppData/Roaming/{rel}"), + ] +} - fs_obj.set( - "readText", - Function::new( - ctx.clone(), - move |ctx_inner: Ctx<'_>, path: String| -> rquickjs::Result { - let expanded = expand_path(&path); - std::fs::read_to_string(&expanded) - .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) - }, - )?, - )?; +fn first_existing_path(paths: &[String]) -> Option { + for path in paths { + let expanded = expand_path(path); + if std::path::Path::new(&expanded).exists() { + return Some(path.clone()); + } + } + None +} - fs_obj.set( - "writeText", - Function::new( - ctx.clone(), - move |ctx_inner: Ctx<'_>, path: String, content: String| -> rquickjs::Result<()> { - let expanded = expand_path(&path); - std::fs::write(&expanded, &content) - .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) - }, - )?, - )?; +fn first_existing_app_support_path(relative: &str) -> Option { + first_existing_path(&app_support_path_candidates(relative)) +} - fs_obj.set( - "listDir", - Function::new( - ctx.clone(), +#[cfg(target_os = "linux")] +fn dbus_session_bus_address() -> Option { + if let Ok(addr) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { + let trimmed = addr.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + let uid = users::get_current_uid(); + let bus_path = format!("/run/user/{uid}/bus"); + if std::path::Path::new(&bus_path).exists() { + return Some(format!("unix:path={bus_path}")); + } + None +} + +#[cfg(target_os = "linux")] +fn read_linux_secret_tool_password(service: &str, account: Option<&str>) -> Result { + let secret_tool = ["secret-tool", "/usr/bin/secret-tool"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()); + let secret_tool = secret_tool.ok_or_else(|| { + "secret-tool not installed (install libsecret-tools for Linux keyring access)".to_string() + })?; + + let mut cmd = std::process::Command::new(secret_tool); + cmd.arg("lookup").arg("service").arg(service); + if let Some(user) = account.map(str::trim).filter(|a| !a.is_empty()) { + cmd.arg("username").arg(user); + } + if let Some(addr) = dbus_session_bus_address() { + cmd.env("DBUS_SESSION_BUS_ADDRESS", addr); + } + + let output = cmd + .output() + .map_err(|e| format!("secret-tool failed: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + return Err(if first_line.is_empty() { + "secret-tool lookup returned no entry".to_string() + } else { + first_line.to_string() + }); + } + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if password.is_empty() { + return Err("secret-tool lookup returned empty secret".to_string()); + } + Ok(password) +} + +/// Windows Credential Manager target for [zalando/go-keyring] (`agy`, Antigravity CLI): `service:username`. +/// The Rust `keyring` crate default is `username.service` instead — try go-keyring layout first. +#[cfg(target_os = "windows")] +fn windows_go_keyring_target(service: &str, user: &str) -> String { + format!("{service}:{user}") +} + +#[cfg(target_os = "windows")] +fn read_windows_keyring_password(service: &str, account: Option<&str>) -> Result { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + let mut errors: Vec = Vec::new(); + + if !user.is_empty() { + let go_target = windows_go_keyring_target(service, user); + match keyring::Entry::new_with_target(&go_target, service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => errors.push(format!("go-keyring target {go_target}: {e}")), + }, + Err(e) => errors.push(format!("go-keyring target {go_target}: {e}")), + } + } + + match keyring::Entry::new(service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => errors.push(format!("keyring crate default: {e}")), + }, + Err(e) => errors.push(format!("keyring crate default: {e}")), + } + + Err(errors.join("; ")) +} + +#[cfg(target_os = "windows")] +fn write_windows_keyring_password( + service: &str, + account: Option<&str>, + value: &str, +) -> Result<(), String> { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + if !user.is_empty() { + let go_target = windows_go_keyring_target(service, user); + if let Ok(entry) = keyring::Entry::new_with_target(&go_target, service, user) { + if entry.set_password(value).is_ok() { + return Ok(()); + } + } + } + keyring::Entry::new(service, user) + .map_err(|e| e.to_string())? + .set_password(value) + .map_err(|e| e.to_string()) +} + +/// Reads a generic password from the platform store (Keychain, Secret Service, Credential Manager). +/// Matches [zalando/go-keyring](https://github.com/zalando/go-keyring) (`service` + `username` on Linux). +fn write_platform_keyring_password( + service: &str, + account: Option<&str>, + value: &str, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return write_windows_keyring_password(service, account, value); + } + #[cfg(not(target_os = "windows"))] + { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + keyring::Entry::new(service, user) + .map_err(|e| e.to_string())? + .set_password(value) + .map_err(|e| e.to_string()) + } +} + +fn read_platform_keyring_password(service: &str, account: Option<&str>) -> Result { + #[cfg(target_os = "windows")] + { + return read_windows_keyring_password(service, account); + } + + #[cfg(not(target_os = "windows"))] + { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + let keyring_err = match keyring::Entry::new(service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => e.to_string(), + }, + Err(e) => e.to_string(), + }; + + #[cfg(target_os = "linux")] + { + return read_linux_secret_tool_password(service, account) + .map_err(|secret_tool_err| format!("{keyring_err}; {secret_tool_err}")); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = keyring_err; + Err("keyring read failed".to_string()) + } + } +} + +fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + let fs_obj = Object::new(ctx.clone())?; + + if let Some(home) = home_dir() { + fs_obj.set("homeDir", home.to_string_lossy().to_string())?; + } + + fs_obj.set( + "exists", + Function::new(ctx.clone(), move |path: String| -> bool { + let expanded = expand_path(&path); + std::path::Path::new(&expanded).exists() + })?, + )?; + + fs_obj.set( + "readText", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, path: String| -> rquickjs::Result { + reject_path_traversal(&ctx_inner, &path)?; + let expanded = expand_path(&path); + std::fs::read_to_string(&expanded) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) + }, + )?, + )?; + + fs_obj.set( + "writeText", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, path: String, content: String| -> rquickjs::Result<()> { + reject_path_traversal(&ctx_inner, &path)?; + let expanded = expand_path(&path); + std::fs::write(&expanded, &content) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) + }, + )?, + )?; + + fs_obj.set( + "listDir", + Function::new( + ctx.clone(), move |ctx_inner: Ctx<'_>, path: String| -> rquickjs::Result> { + reject_path_traversal(&ctx_inner, &path)?; let expanded = expand_path(&path); let entries = std::fs::read_dir(&expanded) .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string()))?; @@ -719,10 +1030,42 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { )?, )?; + fs_obj.set( + "firstExisting", + Function::new(ctx.clone(), move |paths: Vec| -> Option { + first_existing_path(&paths) + })?, + )?; + + fs_obj.set( + "firstExistingAppSupport", + Function::new(ctx.clone(), move |relative: String| -> Option { + first_existing_app_support_path(&relative) + })?, + )?; + host.set("fs", fs_obj)?; Ok(()) } +fn inject_cursor_paths<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + base_plugin_id: &str, +) -> rquickjs::Result<()> { + let paths_obj = Object::new(ctx.clone())?; + let plugin_id = base_plugin_id.to_string(); + paths_obj.set( + "resolveStateDb", + Function::new(ctx.clone(), move |_ctx: Ctx<'_>, ()| -> Option { + crate::cursor_paths::resolve_cursor_state_db_for_plugin_id(&plugin_id) + .map(|p| p.to_string_lossy().to_string()) + })?, + )?; + host.set("cursorPaths", paths_obj)?; + Ok(()) +} + fn inject_crypto<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let crypto_obj = Object::new(ctx.clone())?; @@ -789,6 +1132,67 @@ fn inject_env<'js>(ctx: &Ctx<'js>, host: &Object<'js>, _plugin_id: &str) -> rqui Ok(()) } +fn inject_credentials<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + account: Option<&ProviderAccountContext>, +) -> rquickjs::Result<()> { + let credentials_obj = Object::new(ctx.clone())?; + let credential_json = account + .and_then(|account| account.credential.as_ref()) + .and_then(|credential| serde_json::to_string(credential).ok()); + credentials_obj.set( + "get", + Function::new(ctx.clone(), move || -> Option { + credential_json.clone() + })?, + )?; + + let store_path = account.and_then(|account| account.store_path.clone()); + let instance_id = account.map(|account| account.instance_id.clone()); + credentials_obj.set( + "update", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, update_json: String| -> rquickjs::Result> { + let Some(path) = store_path.as_ref() else { + return Ok(None); + }; + let Some(id) = instance_id.as_ref() else { + return Ok(None); + }; + let update: ProviderCredential = + serde_json::from_str(&update_json).map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("invalid credential update: {}", e), + ) + })?; + let updated = provider_accounts::update_credential_at_path(path, id, update) + .map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("credential update failed: {}", e), + ) + })?; + match updated { + Some(credential) => serde_json::to_string(&credential) + .map(Some) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())), + None => Ok(None), + } + }, + )?, + )?; + + host.set("credentials", credentials_obj)?; + Ok(()) +} + +fn probe_timeout_error<'js>(ctx: &Ctx<'js>) -> rquickjs::Error { + Exception::throw_message(ctx, "probe timed out") +} + fn inject_http<'js>( ctx: &Ctx<'js>, host: &Object<'js>, @@ -808,7 +1212,7 @@ fn inject_http<'js>( })?; if deadline.has_elapsed() { - return Err(Exception::throw_message(&ctx_inner, "probe timed out")); + return Err(probe_timeout_error(&ctx_inner)); } let method_str = req.method.as_deref().unwrap_or("GET"); @@ -835,6 +1239,27 @@ fn inject_http<'js>( } } + #[cfg(test)] + { + let lock = HTTP_MOCK_STATE.get_or_init(|| Mutex::new(None)); + let mut guard = lock.lock().unwrap(); + if let Some(state) = guard.as_mut() { + state.requests.push(CapturedHttpRequest { + url: req.url.clone(), + method: method_str.to_string(), + headers: req.headers.clone().unwrap_or_default(), + body_text: req.body_text.clone(), + }); + let resp = state.responses.pop_front().unwrap_or(HttpRespParams { + status: 200, + headers: std::collections::HashMap::new(), + body_text: "{}".to_string(), + }); + return serde_json::to_string(&resp) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())); + } + } + let timeout_ms = req.timeout_ms.unwrap_or(10_000); let Some(timeout) = deadline.clamp_duration(Duration::from_millis(timeout_ms)) else { @@ -844,15 +1269,9 @@ fn inject_http<'js>( .timeout(timeout) .connect_timeout(timeout) .redirect(reqwest::redirect::Policy::none()); - - // Apply pre-resolved proxy (localhost bypass already configured) - if let Some(resolved) = crate::config::get_resolved_proxy() { + if let Some(resolved) = crate::proxy_config::get_resolved_proxy() { builder = builder.proxy(resolved.proxy.clone()); - log::debug!("[http] proxy active"); - } else { - log::debug!("[http] proxy not used"); } - if req.dangerously_ignore_tls.unwrap_or(false) { builder = builder.danger_accept_invalid_certs(true); } @@ -993,6 +1412,12 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { if (opts.color) line.color = opts.color; if (opts.subtitle) line.subtitle = opts.subtitle; return line; + }, + barChart: function(opts) { + var line = { type: "barChart", label: opts.label, points: opts.points || [] }; + if (opts.note) line.note = opts.note; + if (opts.color) line.color = opts.color; + return line; } }; @@ -1061,6 +1486,47 @@ pub fn inject_utils(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { isAuthStatus: function(status) { return status === 401 || status === 403; }, + readProviderCredential: function() { + try { + if (!ctx.host.credentials || typeof ctx.host.credentials.get !== "function") return null; + var raw = ctx.host.credentials.get(); + if (!raw) return null; + var credential = ctx.util.tryParseJson(String(raw)); + if (!credential) return null; + var accessToken = String(credential.accessToken || credential.sessionKey || "").trim(); + var refreshToken = String(credential.refreshToken || "").trim(); + var sessionKey = credential.sessionKey ? String(credential.sessionKey).trim() : ""; + if (!accessToken && !refreshToken) return null; + return { + accessToken: accessToken || null, + refreshToken: refreshToken || null, + sessionKey: sessionKey || null, + expiresAt: typeof credential.expiresAt === "number" ? credential.expiresAt : null + }; + } catch (e) { + return null; + } + }, + writeProviderCredential: function(partial) { + try { + if (!ctx.host.credentials || typeof ctx.host.credentials.update !== "function") return false; + var current = ctx.util.readProviderCredential() || {}; + var update = Object.assign({}, current, partial || {}); + ctx.host.credentials.update(JSON.stringify({ + accessToken: update.accessToken || null, + refreshToken: update.refreshToken || null, + sessionKey: update.sessionKey || null, + expiresAt: update.expiresAt || null + })); + return true; + } catch (e) { + return false; + } + }, + providerApiKey: function() { + var credential = ctx.util.readProviderCredential(); + return credential && credential.accessToken ? credential.accessToken : null; + }, retryOnceOnAuth: function(opts) { var resp = opts.request(); if (ctx.util.isAuthStatus(resp.status)) { @@ -1268,6 +1734,51 @@ struct HttpRespParams { body_text: String, } +#[cfg(test)] +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CapturedHttpRequest { + pub url: String, + pub method: String, + pub headers: std::collections::HashMap, + pub body_text: Option, +} + +#[cfg(test)] +struct HttpMockState { + responses: VecDeque, + requests: Vec, +} + +#[cfg(test)] +static HTTP_MOCK_STATE: OnceLock>> = OnceLock::new(); + +#[cfg(test)] +pub(crate) fn install_http_mock(responses: Vec<(u16, &str)>) { + let state = HttpMockState { + responses: responses + .into_iter() + .map(|(status, body)| HttpRespParams { + status, + headers: std::collections::HashMap::new(), + body_text: body.to_string(), + }) + .collect(), + requests: Vec::new(), + }; + let lock = HTTP_MOCK_STATE.get_or_init(|| Mutex::new(None)); + *lock.lock().unwrap() = Some(state); +} + +#[cfg(test)] +pub(crate) fn take_http_mock_requests() -> Vec { + let lock = HTTP_MOCK_STATE.get_or_init(|| Mutex::new(None)); + lock.lock() + .unwrap() + .take() + .map(|state| state.requests) + .unwrap_or_default() +} + // --- Language Server Discovery --- #[derive(serde::Deserialize)] @@ -1310,178 +1821,190 @@ fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquick opts.markers ); - let ps_output = match std::process::Command::new("/bin/ps") - .args(["-ax", "-o", "pid=,command="]) - .output() + // Windows: no ps command; plugins fall back to SQLite/Cloud API + #[cfg(windows)] + return Ok("null".to_string()); + + #[cfg(not(windows))] { - Ok(o) => o, - Err(e) => { - log::warn!("[plugin:{}] ps failed: {}", pid, e); + // Use ps -e on Linux (GNU ps); -ax on macOS/BSD + let (ps_path, ps_args): (&str, &[&str]) = if std::env::consts::OS == "linux" { + ("ps", &["-e", "-o", "pid=,args="][..]) + } else { + ("/bin/ps", &["-ax", "-o", "pid=,command="][..]) + }; + let ps_output = match std::process::Command::new(ps_path).args(ps_args).output() + { + Ok(o) => o, + Err(e) => { + log::warn!("[plugin:{}] ps failed: {}", pid, e); + return Ok("null".to_string()); + } + }; + + if !ps_output.status.success() { + log::warn!("[plugin:{}] ps returned non-zero", pid); return Ok("null".to_string()); } - }; - - if !ps_output.status.success() { - log::warn!("[plugin:{}] ps returned non-zero", pid); - return Ok("null".to_string()); - } - let ps_stdout = String::from_utf8_lossy(&ps_output.stdout); - let process_name_lower = opts.process_name.to_lowercase(); - let markers_lower: Vec = - opts.markers.iter().map(|m| m.to_lowercase()).collect(); - - // Find the target process. Marker patterns are Codeium-derived. - // Matching priority: - // 1. Exact --ide_name / --app_data_dir flag value (prevents - // "windsurf" matching "windsurf-next") - // 2. Path substring (//) as fallback when no flags found - let mut found: Option<(i32, String)> = None; - - for line in ps_stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } + let ps_stdout = String::from_utf8_lossy(&ps_output.stdout); + let process_name_lower = opts.process_name.to_lowercase(); + let markers_lower: Vec = + opts.markers.iter().map(|m| m.to_lowercase()).collect(); + + // Find the target process. Marker patterns are Codeium-derived. + // Matching priority: + // 1. Exact --ide_name / --app_data_dir flag value (prevents + // "windsurf" matching "windsurf-next") + // 2. Path substring (//) as fallback when no flags found + let mut found: Option<(i32, String)> = None; + + for line in ps_stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } - let mut parts = trimmed.splitn(2, char::is_whitespace); - let pid_str = match parts.next() { - Some(s) => s.trim(), - None => continue, - }; - let command = match parts.next() { - Some(s) => s.trim(), - None => continue, - }; + let mut parts = trimmed.splitn(2, char::is_whitespace); + let pid_str = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; + let command = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; - let command_lower = command.to_lowercase(); + let command_lower = command.to_lowercase(); - if !command_lower.contains(&process_name_lower) { - continue; - } + if !command_lower.contains(&process_name_lower) { + continue; + } - let ide_name = ls_extract_flag(command, "--ide_name").map(|v| v.to_lowercase()); - let app_data = - ls_extract_flag(command, "--app_data_dir").map(|v| v.to_lowercase()); + let ide_name = + ls_extract_flag(command, "--ide_name").map(|v| v.to_lowercase()); + let app_data = + ls_extract_flag(command, "--app_data_dir").map(|v| v.to_lowercase()); - let has_marker = markers_lower.iter().any(|m| { - // Prefer exact flag match; skip path fallback when - // a distinguishing flag exists. - if let Some(ref name) = ide_name { - return *name == *m; - } - if let Some(ref dir) = app_data { - return *dir == *m; + let has_marker = markers_lower.iter().any(|m| { + // Prefer exact flag match; skip path fallback when + // a distinguishing flag exists. + if let Some(ref name) = ide_name { + return *name == *m; + } + if let Some(ref dir) = app_data { + return *dir == *m; + } + // Fallback: path substring + command_lower.contains(&format!("/{}/", m)) + }); + if !has_marker { + continue; } - // Fallback: path substring - command_lower.contains(&format!("/{}/", m)) - }); - if !has_marker { - continue; - } - if let Ok(p) = pid_str.parse::() { - found = Some((p, command.to_string())); - break; + if let Ok(p) = pid_str.parse::() { + found = Some((p, command.to_string())); + break; + } } - } - let (process_pid, command) = match found { - Some(pair) => pair, - None => { - log::info!("[plugin:{}] LS process not found", pid); - return Ok("null".to_string()); - } - }; + let (process_pid, command) = match found { + Some(pair) => pair, + None => { + log::info!("[plugin:{}] LS process not found", pid); + return Ok("null".to_string()); + } + }; - // Extract CSRF token - let csrf = match ls_extract_flag(&command, &opts.csrf_flag) { - Some(c) => c, - None => { - log::warn!("[plugin:{}] CSRF token not found in process args", pid); - return Ok("null".to_string()); - } - }; + // Extract CSRF token + let csrf = match ls_extract_flag(&command, &opts.csrf_flag) { + Some(c) => c, + None => { + log::warn!("[plugin:{}] CSRF token not found in process args", pid); + return Ok("null".to_string()); + } + }; - // Extract extension port (optional) - let extension_port = opts.port_flag.as_ref().and_then(|flag| { - ls_extract_flag(&command, flag).and_then(|v| v.parse::().ok()) - }); + // Extract extension port (optional) + let extension_port = opts.port_flag.as_ref().and_then(|flag| { + ls_extract_flag(&command, flag).and_then(|v| v.parse::().ok()) + }); - // Extract extra flags (optional) - let mut extra = std::collections::HashMap::new(); - if let Some(ref flags) = opts.extra_flags { - for flag in flags { - if let Some(val) = ls_extract_flag(&command, flag) { - // Use flag name without leading dashes as key - let key = flag.trim_start_matches('-').to_string(); - extra.insert(key, val); + // Extract extra flags (optional) + let mut extra = std::collections::HashMap::new(); + if let Some(ref flags) = opts.extra_flags { + for flag in flags { + if let Some(val) = ls_extract_flag(&command, flag) { + // Use flag name without leading dashes as key + let key = flag.trim_start_matches('-').to_string(); + extra.insert(key, val); + } } } - } - // Find lsof binary - let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] - .iter() - .find(|p| std::path::Path::new(p).exists()) - .copied(); - - let ports = if let Some(lsof) = lsof_path { - match std::process::Command::new(lsof) - .args([ - "-nP", - "-iTCP", - "-sTCP:LISTEN", - "-a", - "-p", - &process_pid.to_string(), - ]) - .output() - { - Ok(o) if o.status.success() => { - ls_parse_listening_ports(&String::from_utf8_lossy(&o.stdout)) - } - Ok(_) => { - log::warn!("[plugin:{}] lsof returned non-zero", pid); - Vec::new() - } - Err(e) => { - log::warn!("[plugin:{}] lsof failed: {}", pid, e); - Vec::new() + // Find lsof binary + let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] + .iter() + .find(|p| std::path::Path::new(p).exists()) + .copied(); + + let ports = if let Some(lsof) = lsof_path { + match std::process::Command::new(lsof) + .args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-a", + "-p", + &process_pid.to_string(), + ]) + .output() + { + Ok(o) if o.status.success() => { + ls_parse_listening_ports(&String::from_utf8_lossy(&o.stdout)) + } + Ok(_) => { + log::warn!("[plugin:{}] lsof returned non-zero", pid); + Vec::new() + } + Err(e) => { + log::warn!("[plugin:{}] lsof failed: {}", pid, e); + Vec::new() + } } + } else { + log::warn!("[plugin:{}] lsof not found", pid); + Vec::new() + }; + + if ports.is_empty() && extension_port.is_none() { + log::warn!( + "[plugin:{}] no listening ports found for pid {}", + pid, + process_pid + ); + return Ok("null".to_string()); } - } else { - log::warn!("[plugin:{}] lsof not found", pid); - Vec::new() - }; - if ports.is_empty() && extension_port.is_none() { - log::warn!( - "[plugin:{}] no listening ports found for pid {}", + log::info!( + "[plugin:{}] LS found: pid={}, ports={:?}, csrf=[REDACTED]", pid, - process_pid + process_pid, + ports ); - return Ok("null".to_string()); - } - log::info!( - "[plugin:{}] LS found: pid={}, ports={:?}, csrf=[REDACTED]", - pid, - process_pid, - ports - ); - - let result = LsDiscoverResult { - pid: process_pid, - csrf, - ports, - extra, - extension_port, - }; + let result = LsDiscoverResult { + pid: process_pid, + csrf, + ports, + extra, + extension_port, + }; - serde_json::to_string(&result).map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("serialize failed: {}", e)) - }) + serde_json::to_string(&result).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("serialize failed: {}", e)) + }) + } }, )?, )?; @@ -1573,6 +2096,10 @@ struct CcusageQueryOpts { enum CcusageProvider { Claude, Codex, + Amp, + Kimi, + Copilot, + OpenCode, } static CCUSAGE_ACTIVE_PROVIDERS: OnceLock>> = OnceLock::new(); @@ -1638,13 +2165,17 @@ fn ccusage_runner_label(kind: CcusageRunnerKind) -> &'static str { #[derive(Copy, Clone)] struct CcusageProviderConfig { command_namespace: &'static str, - home_env_var: &'static str, + home_env_var: Option<&'static str>, } fn parse_ccusage_provider(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "claude" => Some(CcusageProvider::Claude), "codex" => Some(CcusageProvider::Codex), + "amp" => Some(CcusageProvider::Amp), + "kimi" => Some(CcusageProvider::Kimi), + "copilot" => Some(CcusageProvider::Copilot), + "opencode" | "opencode-go" => Some(CcusageProvider::OpenCode), _ => None, } } @@ -1653,24 +2184,115 @@ fn infer_ccusage_provider(plugin_id: &str) -> Option { parse_ccusage_provider(plugin_id) } -fn resolve_ccusage_provider(opts: &CcusageQueryOpts, plugin_id: &str) -> CcusageProvider { +fn resolve_ccusage_provider(opts: &CcusageQueryOpts, plugin_id: &str) -> Option { opts.provider .as_deref() .and_then(parse_ccusage_provider) .or_else(|| infer_ccusage_provider(plugin_id)) - .unwrap_or(CcusageProvider::Claude) } fn ccusage_provider_config(provider: CcusageProvider) -> CcusageProviderConfig { match provider { CcusageProvider::Claude => CcusageProviderConfig { command_namespace: "claude", - home_env_var: "CLAUDE_CONFIG_DIR", + home_env_var: Some("CLAUDE_CONFIG_DIR"), }, CcusageProvider::Codex => CcusageProviderConfig { command_namespace: "codex", - home_env_var: "CODEX_HOME", + home_env_var: Some("CODEX_HOME"), + }, + CcusageProvider::Amp => CcusageProviderConfig { + command_namespace: "amp", + home_env_var: None, + }, + CcusageProvider::Kimi => CcusageProviderConfig { + command_namespace: "kimi", + home_env_var: None, + }, + CcusageProvider::Copilot => CcusageProviderConfig { + command_namespace: "copilot", + home_env_var: None, }, + CcusageProvider::OpenCode => CcusageProviderConfig { + command_namespace: "opencode", + home_env_var: None, + }, + } +} + +fn ccusage_supports_legacy_fallback(provider: CcusageProvider) -> bool { + matches!(provider, CcusageProvider::Claude | CcusageProvider::Codex) +} + +/// True when ccusage can read local CLI logs for this OpenUsage plugin id. +pub fn ccusage_supported_plugin_id(plugin_id: &str) -> bool { + infer_ccusage_provider(plugin_id).is_some() +} + +fn ccusage_since_days_ago(days: i64) -> String { + let d = time::OffsetDateTime::now_utc().date() - time::Duration::days(days); + format!("{:04}{:02}{:02}", d.year(), u8::from(d.month()), d.day()) +} + +/// After a successful probe, ingest ccusage daily rows for plugins that do not run ccusage in JS. +pub fn post_probe_ccusage_daily( + app_data_dir: &std::path::Path, + plugin_id: &str, + display_name: &str, +) { + if !crate::usage_history::persist_usage_history_enabled(app_data_dir) { + return; + } + let Some(provider) = infer_ccusage_provider(plugin_id) else { + return; + }; + // Claude/Codex plugins already query ccusage during probe (card sparkline + ingest). + if matches!(provider, CcusageProvider::Claude | CcusageProvider::Codex) { + return; + } + + let opts = CcusageQueryOpts { + since: Some(ccusage_since_days_ago(31)), + ..CcusageQueryOpts::default() + }; + let Some(_guard) = CcusageQueryGuard::acquire(provider) else { + log::debug!( + "[plugin:{}] ccusage post-probe skipped: query already running", + plugin_id + ); + return; + }; + let runners = collect_ccusage_runners(); + let result_json = run_ccusage_query_with_runners( + runners, + &opts, + provider, + plugin_id, + run_ccusage_with_runner, + ); + let Ok(parsed) = serde_json::from_str::(&result_json) else { + return; + }; + if parsed.get("status").and_then(|v| v.as_str()) != Some("ok") { + return; + } + let Some(daily) = parsed.pointer("/data/daily") else { + return; + }; + if !daily.is_array() || daily.as_array().is_some_and(|a| a.is_empty()) { + return; + } + let payload = serde_json::json!({ + "displayName": display_name, + "source": "ccusage", + "daily": daily, + }); + if let Err(e) = crate::usage_daily::ingest_json(app_data_dir, plugin_id, &payload.to_string()) { + log::debug!( + "[plugin:{}] ccusage post-probe ingest failed: {}", + plugin_id, + e + ); } } @@ -1682,6 +2304,10 @@ fn ccusage_legacy_package_spec(provider: CcusageProvider) -> String { let package_name = match provider { CcusageProvider::Claude => CCUSAGE_LEGACY_CLAUDE_PACKAGE_NAME, CcusageProvider::Codex => CCUSAGE_LEGACY_CODEX_PACKAGE_NAME, + CcusageProvider::Amp + | CcusageProvider::Kimi + | CcusageProvider::Copilot + | CcusageProvider::OpenCode => CCUSAGE_PACKAGE_NAME, }; format!("{}@{}", package_name, CCUSAGE_LEGACY_VERSION) } @@ -1705,7 +2331,11 @@ fn ccusage_home_override<'a>( .as_deref() .map(str::trim) .filter(|s| !s.is_empty()), - CcusageProvider::Codex => None, + CcusageProvider::Codex + | CcusageProvider::Amp + | CcusageProvider::Kimi + | CcusageProvider::Copilot + | CcusageProvider::OpenCode => None, } } @@ -1787,13 +2417,10 @@ fn ccusage_path_entries_with(home: Option<&Path>, existing_path: Option<&OsStr>) entries.push(nvm_bin); } entries.push(home.join(".local/bin")); - // Common Linux tool locations (asdf shims, cargo, rustup, etc.). - entries.push(home.join(".cargo/bin")); - entries.push(home.join(".asdf/shims")); } entries.extend( - ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] + ["/opt/homebrew/bin", "/usr/local/bin"] .into_iter() .map(PathBuf::from), ); @@ -1940,9 +2567,13 @@ fn ccusage_runner_args( CcusageCommandFlavor::Legacy => ccusage_legacy_package_spec(provider), }; let npm_exec_bin = match (flavor, provider) { - (CcusageCommandFlavor::Current, _) => CCUSAGE_BIN_NAME, - (CcusageCommandFlavor::Legacy, CcusageProvider::Claude) => CCUSAGE_BIN_NAME, + (CcusageCommandFlavor::Current, _) + | (CcusageCommandFlavor::Legacy, CcusageProvider::Claude) => CCUSAGE_BIN_NAME, (CcusageCommandFlavor::Legacy, CcusageProvider::Codex) => CCUSAGE_LEGACY_CODEX_BIN_NAME, + (CcusageCommandFlavor::Legacy, CcusageProvider::Amp) + | (CcusageCommandFlavor::Legacy, CcusageProvider::Kimi) + | (CcusageCommandFlavor::Legacy, CcusageProvider::Copilot) + | (CcusageCommandFlavor::Legacy, CcusageProvider::OpenCode) => CCUSAGE_BIN_NAME, }; let mut args: Vec = match kind { CcusageRunnerKind::Bunx => vec!["--silent".to_string(), package_spec.clone()], @@ -2057,7 +2688,6 @@ fn format_ccusage_timeout(timeout: std::time::Duration) -> String { format!("{:.3}s", timeout.as_secs_f64()) } -#[cfg(test)] fn run_ccusage_with_runner( kind: CcusageRunnerKind, program: &str, @@ -2065,35 +2695,6 @@ fn run_ccusage_with_runner( provider: CcusageProvider, plugin_id: &str, ) -> CcusageRunnerResult { - run_ccusage_with_runner_deadline( - kind, - program, - opts, - provider, - plugin_id, - ProbeDeadline::none(), - ) -} - -fn run_ccusage_with_runner_deadline( - kind: CcusageRunnerKind, - program: &str, - opts: &CcusageQueryOpts, - provider: CcusageProvider, - plugin_id: &str, - deadline: ProbeDeadline, -) -> CcusageRunnerResult { - if deadline.has_elapsed() { - log::warn!("[plugin:{}] ccusage skipped: probe timed out", plugin_id); - return CcusageRunnerResult::TimedOut; - } - - let Some(current_timeout) = deadline.clamp_duration(Duration::from_secs(CCUSAGE_TIMEOUT_SECS)) - else { - log_probe_deadline_skip(plugin_id, "ccusage"); - return CcusageRunnerResult::TimedOut; - }; - let current = run_ccusage_with_runner_timeout( kind, program, @@ -2101,17 +2702,10 @@ fn run_ccusage_with_runner_deadline( provider, plugin_id, CcusageCommandFlavor::Current, - current_timeout, + std::time::Duration::from_secs(CCUSAGE_TIMEOUT_SECS), ); match current { - CcusageRunnerResult::Failed if deadline.has_elapsed() => CcusageRunnerResult::TimedOut, - CcusageRunnerResult::Failed => { - let Some(legacy_timeout) = - deadline.clamp_duration(Duration::from_secs(CCUSAGE_TIMEOUT_SECS)) - else { - log_probe_deadline_skip(plugin_id, "ccusage legacy fallback"); - return CcusageRunnerResult::TimedOut; - }; + CcusageRunnerResult::Failed if ccusage_supports_legacy_fallback(provider) => { run_ccusage_with_runner_timeout( kind, program, @@ -2119,7 +2713,7 @@ fn run_ccusage_with_runner_deadline( provider, plugin_id, CcusageCommandFlavor::Legacy, - legacy_timeout, + std::time::Duration::from_secs(CCUSAGE_TIMEOUT_SECS), ) } other => other, @@ -2142,7 +2736,9 @@ fn run_ccusage_with_runner_timeout( if let Some(home_path) = ccusage_home_override(opts, provider) { let config = ccusage_provider_config(provider); - command.env(config.home_env_var, expand_path(&home_path)); + if let Some(home_env) = config.home_env_var { + command.env(home_env, expand_path(home_path)); + } } let redacted_program = redact_log_message(program); @@ -2329,6 +2925,10 @@ fn inject_ccusage<'js>( Function::new( ctx.clone(), move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + if deadline.has_elapsed() { + log_probe_deadline_skip(&pid, "ccusage"); + return Ok(serde_json::json!({ "status": "runner_failed" }).to_string()); + } let opts: CcusageQueryOpts = match serde_json::from_str(&opts_json) { Ok(v) => v, Err(e) => { @@ -2336,7 +2936,9 @@ fn inject_ccusage<'js>( CcusageQueryOpts::default() } }; - let provider = resolve_ccusage_provider(&opts, &pid); + let Some(provider) = resolve_ccusage_provider(&opts, &pid) else { + return Ok(serde_json::json!({ "status": "unsupported" }).to_string()); + }; let Some(_active_query) = CcusageQueryGuard::acquire(provider) else { log::warn!("[plugin:{}] ccusage query already running", pid); return Ok(serde_json::json!({ "status": "runner_failed" }).to_string()); @@ -2347,11 +2949,7 @@ fn inject_ccusage<'js>( &opts, provider, &pid, - |kind, program, opts, provider, plugin_id| { - run_ccusage_with_runner_deadline( - kind, program, opts, provider, plugin_id, deadline, - ) - }, + run_ccusage_with_runner, )) }, )?, @@ -2361,398 +2959,1082 @@ fn inject_ccusage<'js>( Ok(()) } -pub fn patch_ccusage_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { - ctx.eval::<(), _>( - r#" - (function() { - var rawFn = __openusage_ctx.host.ccusage._queryRaw; - __openusage_ctx.host.ccusage.query = function(opts) { - var result = rawFn(JSON.stringify(opts || {})); - try { - var parsed = JSON.parse(result); - if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { - return parsed; - } - } catch (e) {} - return { status: "runner_failed" }; - }; - })(); - "# - .as_bytes(), - ) -} - -/// Look up a secret via the freedesktop Secret Service (libsecret `secret-tool`). -#[cfg(target_os = "linux")] -fn secret_tool_lookup(attrs: &[(&str, &str)]) -> Result { - let mut cmd = std::process::Command::new("secret-tool"); - cmd.arg("lookup"); - for (key, value) in attrs { - cmd.arg(key).arg(value); - } - let output = cmd - .output() - .map_err(|e| format!("secret-tool not available (install libsecret): {}", e))?; - if !output.status.success() { - return Err("keychain item not found".to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout) - .trim_end_matches('\n') - .to_string()) -} - -/// Store a secret via the freedesktop Secret Service (libsecret `secret-tool`). -#[cfg(target_os = "linux")] -fn secret_tool_store(label: &str, value: &str, attrs: &[(&str, &str)]) -> Result<(), String> { - use std::io::Write; - use std::process::Stdio; - let mut cmd = std::process::Command::new("secret-tool"); - cmd.arg("store").arg("--label").arg(label); - for (key, attr_value) in attrs { - cmd.arg(key).arg(attr_value); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - let mut child = cmd - .spawn() - .map_err(|e| format!("secret-tool not available (install libsecret): {}", e))?; - child - .stdin - .take() - .ok_or_else(|| "secret-tool stdin unavailable".to_string())? - .write_all(value.as_bytes()) - .map_err(|e| format!("secret-tool write failed: {}", e))?; - let output = child - .wait_with_output() - .map_err(|e| format!("secret-tool store failed: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "keychain write failed: {}", - stderr.lines().next().unwrap_or("").trim() - )); - } - Ok(()) -} - -/// Read a generic password from the platform credential store. -/// macOS: Keychain via `security`. Linux: Secret Service via `secret-tool`. -fn keychain_op_read(service: &str, account: Option<&str>) -> Result { - #[cfg(target_os = "macos")] - { - let args = match account { - Some(a) => keychain_find_generic_password_args_for_account(service, a), - None => keychain_find_generic_password_args(service), - }; - let output = std::process::Command::new("security") - .args(args) - .output() - .map_err(|e| format!("keychain read failed: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "keychain item not found: {}", - stderr.lines().next().unwrap_or("").trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) - } - #[cfg(target_os = "linux")] - { - match account { - Some(a) => secret_tool_lookup(&[("service", service), ("account", a)]), - None => secret_tool_lookup(&[("service", service)]), - } - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - let _ = (service, account); - Err("keychain API is not supported on this platform".to_string()) - } -} +fn inject_usage_daily<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + instance_id: &str, + app_data_dir: &PathBuf, +) -> rquickjs::Result<()> { + let usage_daily_obj = Object::new(ctx.clone())?; + let app_data = app_data_dir.clone(); + let iid = instance_id.to_string(); -/// Write a generic password to the platform credential store. -fn keychain_op_write(service: &str, account: Option<&str>, value: &str) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - // When no account is supplied, reuse the existing item's account (if any) - // so we update in place rather than create a duplicate entry. - let resolved_account = match account { - Some(a) => Some(a.to_string()), - None => { - let mut found: Option = None; - if let Ok(output) = std::process::Command::new("security") - .args(["find-generic-password", "-s", service]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - if let Some(start) = line.find("\"acct\"=\"") { - let rest = &line[start + 14..]; - if let Some(end) = rest.find('"') { - found = Some(rest[..end].to_string()); - break; - } - } - } - } + usage_daily_obj.set( + "_ingestRaw", + Function::new( + ctx.clone(), + move |_ctx_inner: Ctx<'_>, payload_json: String| -> rquickjs::Result<()> { + if let Err(e) = crate::usage_daily::ingest_json(&app_data, &iid, &payload_json) { + log::warn!("[plugin:{}] usageDaily.ingest failed: {}", iid, e); } - found - } - }; - - let output = match resolved_account.as_deref() { - Some(acct) => std::process::Command::new("security") - .args(keychain_add_generic_password_args_for_account( - service, acct, value, - )) - .output(), - None => std::process::Command::new("security") - .args(keychain_add_generic_password_args(service, value)) - .output(), - } - .map_err(|e| format!("keychain write failed: {}", e))?; + Ok(()) + }, + )?, + )?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!( - "keychain write failed: {}", - stderr.lines().next().unwrap_or("").trim() - )); - } - Ok(()) - } - #[cfg(target_os = "linux")] - { - match account { - Some(a) => secret_tool_store(service, value, &[("service", service), ("account", a)]), - None => secret_tool_store(service, value, &[("service", service)]), - } - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - let _ = (service, account, value); - Err("keychain API is not supported on this platform".to_string()) - } + host.set("usageDaily", usage_daily_obj)?; + Ok(()) } -fn inject_keychain<'js>( +fn inject_cursor_logs<'js>( ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str, + deadline: ProbeDeadline, ) -> rquickjs::Result<()> { - let keychain_obj = Object::new(ctx.clone())?; - let pid_read = plugin_id.to_string(); + let cursor_logs_obj = Object::new(ctx.clone())?; + let pid = plugin_id.to_string(); - keychain_obj.set( - "readGenericPassword", + cursor_logs_obj.set( + "_queryRaw", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { - log::info!("[plugin:{}] keychain read: service={}", pid_read, service); - match keychain_op_read(&service, None) { - Ok(secret) => { - log::info!( - "[plugin:{}] keychain read hit: service={}", - pid_read, - service - ); - Ok(secret) - } - Err(e) => { - log::warn!( - "[plugin:{}] keychain read miss: service={}, error={}", - pid_read, - service, - e - ); - Err(Exception::throw_message(&ctx_inner, &e)) - } + move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + if deadline.has_elapsed() { + log_probe_deadline_skip(&pid, "cursorLogs"); + return Ok( + serde_json::json!({ "status": "no_data", "data": { "daily": [] } }) + .to_string(), + ); } + let since = serde_json::from_str::(&opts_json) + .ok() + .and_then(|v| { + v.get("since") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + let since = if since.is_empty() { + let d = time::OffsetDateTime::now_utc().date() - time::Duration::days(30); + format!("{:04}{:02}{:02}", d.year(), u8::from(d.month()), d.day()) + } else { + since + }; + let (status, daily) = crate::cursor_usage_logs::query_daily_since(&since); + let status_str = match status { + crate::cursor_usage_logs::CursorLogsStatus::Ok => "ok", + crate::cursor_usage_logs::CursorLogsStatus::NoData => "no_data", + }; + Ok(serde_json::json!({ + "status": status_str, + "data": { "daily": daily } + }) + .to_string()) }, )?, )?; - let pid_read_current_user = plugin_id.to_string(); - keychain_obj.set( - "readGenericPasswordForCurrentUser", + host.set("cursorLogs", cursor_logs_obj)?; + Ok(()) +} + +fn inject_claude_logs<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + deadline: ProbeDeadline, +) -> rquickjs::Result<()> { + let obj = Object::new(ctx.clone())?; + obj.set( + "_queryRaw", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { - let account = current_macos_keychain_account(); - let redacted_account = redact_value(&account); - log::info!( - "[plugin:{}] keychain read: service={}, account={}", - pid_read_current_user, - service, - redacted_account - ); - match keychain_op_read(&service, Some(&account)) { - Ok(secret) => { - log::info!( - "[plugin:{}] keychain read hit: service={}, account={}", - pid_read_current_user, - service, - redacted_account - ); - Ok(secret) - } - Err(e) => { - log::warn!( - "[plugin:{}] keychain read miss: service={}, account={}, error={}", - pid_read_current_user, - service, - redacted_account, - e - ); - Err(Exception::throw_message(&ctx_inner, &e)) - } + move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok( + serde_json::json!({ "status": "no_data", "data": { "daily": [] } }) + .to_string(), + ); } + Ok(crate::claude_usage_scanner::query_daily_host_json( + &opts_json, + )) }, )?, )?; + host.set("claudeLogs", obj)?; + Ok(()) +} - let pid_write = plugin_id.to_string(); - keychain_obj.set( - "writeGenericPassword", +fn inject_codex_logs<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + deadline: ProbeDeadline, +) -> rquickjs::Result<()> { + let obj = Object::new(ctx.clone())?; + obj.set( + "_queryRaw", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, service: String, value: String| -> rquickjs::Result<()> { - log::info!("[plugin:{}] keychain write: service={}", pid_write, service); - match keychain_op_write(&service, None, &value) { - Ok(()) => { - log::info!( - "[plugin:{}] keychain write succeeded: service={}", - pid_write, - service - ); - Ok(()) - } - Err(e) => { - log::warn!( - "[plugin:{}] keychain write failed: service={}, error={}", - pid_write, - service, - e - ); - Err(Exception::throw_message(&ctx_inner, &e)) - } + move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok( + serde_json::json!({ "status": "no_data", "data": { "daily": [] } }) + .to_string(), + ); } + Ok(crate::codex_usage_scanner::query_daily_host_json( + &opts_json, + )) }, )?, )?; + host.set("codexLogs", obj)?; + Ok(()) +} - let pid_write_current_user = plugin_id.to_string(); - keychain_obj.set( - "writeGenericPasswordForCurrentUser", +fn inject_cursor_usage_export<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + deadline: ProbeDeadline, +) -> rquickjs::Result<()> { + let export_obj = Object::new(ctx.clone())?; + export_obj.set( + "_queryMtdRaw", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, service: String, value: String| -> rquickjs::Result<()> { - let account = current_macos_keychain_account(); + move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_mtd_host_json(&opts)) + }, + )?, + )?; + export_obj.set( + "_queryStatsRaw", + Function::new( + ctx.clone(), + move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_usage_stats_host_json( + &opts, + )) + }, + )?, + )?; + export_obj.set( + "_queryDailyRaw", + Function::new( + ctx.clone(), + move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_daily_billing_host_json( + &opts, + )) + }, + )?, + )?; + host.set("cursorUsageExport", export_obj)?; + Ok(()) +} + +pub fn patch_cursor_usage_export_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + function cursorExportOpts(opts) { + var o = opts && typeof opts === "object" ? Object.assign({}, opts) : {}; + if (!o.pluginId && __openusage_ctx.account && __openusage_ctx.account.baseProviderId) { + o.pluginId = __openusage_ctx.account.baseProviderId; + } + return JSON.stringify(o); + } + var rawFn = __openusage_ctx.host.cursorUsageExport._queryMtdRaw; + __openusage_ctx.host.cursorUsageExport.queryMtd = function(opts) { + var result = rawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid MTD response" }; + }; + var statsRawFn = __openusage_ctx.host.cursorUsageExport._queryStatsRaw; + __openusage_ctx.host.cursorUsageExport.queryStats = function(opts) { + var result = statsRawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid usage stats response" }; + }; + var dailyRawFn = __openusage_ctx.host.cursorUsageExport._queryDailyRaw; + __openusage_ctx.host.cursorUsageExport.queryDaily = function(opts) { + var result = dailyRawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid daily billing response" }; + }; + })(); + "# + .as_bytes(), + ) +} + +pub fn patch_cursor_logs_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.cursorLogs._queryRaw; + __openusage_ctx.host.cursorLogs.queryDaily = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "no_data", data: { daily: [] } }; + }; + })(); + "# + .as_bytes(), + ) +} + +pub fn patch_claude_logs_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.claudeLogs._queryRaw; + __openusage_ctx.host.claudeLogs.queryDaily = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "no_data", data: { daily: [] } }; + }; + })(); + "# + .as_bytes(), + ) +} + +pub fn patch_codex_logs_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.codexLogs._queryRaw; + __openusage_ctx.host.codexLogs.queryDaily = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "no_data", data: { daily: [] } }; + }; + })(); + "# + .as_bytes(), + ) +} + +pub fn patch_usage_daily_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.usageDaily._ingestRaw; + __openusage_ctx.host.usageDaily.ingest = function(opts) { + rawFn(JSON.stringify(opts || {})); + }; + })(); + "# + .as_bytes(), + ) +} + +pub fn patch_ccusage_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.ccusage._queryRaw; + __openusage_ctx.host.ccusage.query = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "runner_failed" }; + }; + })(); + "# + .as_bytes(), + ) +} + +#[derive(Default, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FireworksBillingExportOpts { + api_key: String, + account_id: String, + start_time: String, + end_time: String, +} + +fn firectl_runner_candidates() -> [&'static str; 3] { + [ + "firectl", + "/opt/homebrew/bin/firectl", + "/usr/local/bin/firectl", + ] +} + +fn resolve_firectl_runner() -> Option { + static FIRECTL_RUNNER: OnceLock> = OnceLock::new(); + FIRECTL_RUNNER + .get_or_init(|| { + for candidate in firectl_runner_candidates() { + if Command::new(candidate) + .arg("--help") + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + return Some(candidate.to_string()); + } + } + None + }) + .clone() +} + +fn fireworks_auth_ini_contents(api_key: &str) -> String { + format!("[fireworks]\napi_key = {}\n", api_key) +} + +fn write_fireworks_auth_ini(auth_root: &Path, api_key: &str) -> std::io::Result { + let fireworks_dir = auth_root.join(".fireworks"); + std::fs::create_dir_all(&fireworks_dir)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&fireworks_dir, std::fs::Permissions::from_mode(0o700))?; + } + + let auth_ini_path = fireworks_dir.join("auth.ini"); + std::fs::write(&auth_ini_path, fireworks_auth_ini_contents(api_key))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&auth_ini_path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(auth_ini_path) +} + +fn cleanup_fireworks_export_dir(path: &Path) { + let _ = std::fs::remove_dir_all(path); +} + +fn run_fireworks_billing_export_timeout( + command: &mut Command, + plugin_id: &str, + timeout: std::time::Duration, +) -> Result { + let mut child = match command.spawn() { + Ok(child) => child, + Err(err) => { + log::warn!( + "[plugin:{}] failed to spawn firectl billing export: {}", + plugin_id, + err + ); + return Err("runner_failed"); + } + }; + + let mut stdout_reader = child.stdout.take().map(|mut stdout| { + std::thread::spawn(move || { + let mut v = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stdout, &mut v); + v + }) + }); + let mut stderr_reader = child.stderr.take().map(|mut stderr| { + std::thread::spawn(move || { + let mut v = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stderr, &mut v); + v + }) + }); + + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = stdout_reader + .take() + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(); + let stderr = stderr_reader + .take() + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(); + return Ok(std::process::Output { + status, + stdout, + stderr, + }); + } + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.take().and_then(|reader| reader.join().ok()); + let _ = stderr_reader.take().and_then(|reader| reader.join().ok()); + log::warn!( + "[plugin:{}] firectl billing export timed out after {}s", + plugin_id, + timeout.as_secs() + ); + return Err("timed_out"); + } + std::thread::sleep(std::time::Duration::from_millis(FIRECTL_POLL_INTERVAL_MS)); + } + Err(err) => { + log::warn!( + "[plugin:{}] firectl billing export wait failed: {}", + plugin_id, + err + ); + let _ = stdout_reader.take().and_then(|reader| reader.join().ok()); + let _ = stderr_reader.take().and_then(|reader| reader.join().ok()); + return Err("runner_failed"); + } + } + } +} + +fn run_fireworks_billing_export( + opts: &FireworksBillingExportOpts, + plugin_id: &str, +) -> serde_json::Value { + if opts.api_key.trim().is_empty() + || opts.account_id.trim().is_empty() + || opts.start_time.trim().is_empty() + || opts.end_time.trim().is_empty() + { + return serde_json::json!({ "status": "invalid_opts" }); + } + + let Some(program) = resolve_firectl_runner() else { + log::warn!( + "[plugin:{}] firectl not found for billing export", + plugin_id + ); + return serde_json::json!({ "status": "no_runner" }); + }; + + let workspace_name = format!( + "openusage-fireworks-{}-{}", + std::process::id(), + iso_now().replace([':', '.'], "-") + ); + let temp_dir = std::env::temp_dir().join(&workspace_name); + if let Err(err) = std::fs::create_dir_all(&temp_dir) { + log::warn!( + "[plugin:{}] failed to create Fireworks export temp dir: {}", + plugin_id, + err + ); + return serde_json::json!({ "status": "runner_failed" }); + } + + if let Err(err) = write_fireworks_auth_ini(&temp_dir, opts.api_key.trim()) { + cleanup_fireworks_export_dir(&temp_dir); + log::warn!( + "[plugin:{}] failed to prepare Fireworks auth config: {}", + plugin_id, + err + ); + return serde_json::json!({ "status": "runner_failed" }); + } + + let file_name = format!("{}.csv", workspace_name); + let output_path = temp_dir.join(&file_name); + let mut command = Command::new(&program); + command + .current_dir(&temp_dir) + .env("HOME", &temp_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .args([ + "billing", + "export-metrics", + "--account-id", + opts.account_id.trim(), + "--start-time", + opts.start_time.trim(), + "--end-time", + opts.end_time.trim(), + "--filename", + file_name.as_str(), + ]); + let result = run_fireworks_billing_export_timeout( + &mut command, + plugin_id, + std::time::Duration::from_secs(FIRECTL_TIMEOUT_SECS), + ); + + let read_csv = || std::fs::read_to_string(&output_path).ok(); + let cleanup = || cleanup_fireworks_export_dir(&temp_dir); + + match result { + Ok(output) if output.status.success() => { + let csv = read_csv(); + cleanup(); + match csv { + Some(text) if !text.trim().is_empty() => { + serde_json::json!({ "status": "ok", "csv": text }) + } + _ => { + log::warn!( + "[plugin:{}] billing export succeeded but no CSV was produced", + plugin_id + ); + serde_json::json!({ "status": "empty" }) + } + } + } + Ok(output) => { + cleanup(); + let stderr = String::from_utf8_lossy(&output.stderr); + log::warn!( + "[plugin:{}] firectl billing export failed: {}", + plugin_id, + stderr.lines().next().unwrap_or("unknown error").trim() + ); + serde_json::json!({ "status": "runner_failed" }) + } + Err(status) => { + cleanup(); + serde_json::json!({ "status": status }) + } + } +} + +fn inject_fireworks<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + plugin_id: &str, +) -> rquickjs::Result<()> { + let fireworks_obj = Object::new(ctx.clone())?; + let pid = plugin_id.to_string(); + + fireworks_obj.set( + "_exportBillingMetricsRaw", + Function::new( + ctx.clone(), + move |_ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { + let opts: FireworksBillingExportOpts = + serde_json::from_str(&opts_json).unwrap_or_default(); + Ok(run_fireworks_billing_export(&opts, &pid).to_string()) + }, + )?, + )?; + + host.set("fireworks", fireworks_obj)?; + Ok(()) +} + +pub fn patch_fireworks_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + var rawFn = __openusage_ctx.host.fireworks._exportBillingMetricsRaw; + __openusage_ctx.host.fireworks.exportBillingMetrics = function(opts) { + var result = rawFn(JSON.stringify(opts || {})); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "runner_failed" }; + }; + })(); + "# + .as_bytes(), + ) +} + +fn inject_keychain<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + plugin_id: &str, +) -> rquickjs::Result<()> { + let keychain_obj = Object::new(ctx.clone())?; + let pid_read = plugin_id.to_string(); + + keychain_obj.set( + "readGenericPassword", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, + service: String, + account_args: Rest>| + -> rquickjs::Result { + let account = account_args + .0 + .into_iter() + .next() + .flatten() + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + let redacted_account = account.as_deref().map(redact_value); + if let Some(ref redacted) = redacted_account { + log::info!( + "[plugin:{}] keychain read: service={}, account={}", + pid_read, + service, + redacted + ); + } else { + log::info!("[plugin:{}] keychain read: service={}", pid_read, service); + } + + if cfg!(target_os = "macos") { + let args = if let Some(ref account) = account { + keychain_find_generic_password_args_for_account(&service, account) + } else { + keychain_find_generic_password_args(&service) + }; + let output = std::process::Command::new("security") + .args(args) + .output() + .map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("keychain read failed: {}", e), + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + if let Some(ref redacted) = redacted_account { + log::warn!( + "[plugin:{}] keychain read miss: service={}, account={}, error={}", + pid_read, + service, + redacted, + first_line + ); + } else { + log::warn!( + "[plugin:{}] keychain read miss: service={}, error={}", + pid_read, + service, + first_line + ); + } + return Err(Exception::throw_message( + &ctx_inner, + &format!("keychain item not found: {}", first_line), + )); + } + + if let Some(ref redacted) = redacted_account { + log::info!( + "[plugin:{}] keychain read hit: service={}, account={}", + pid_read, + service, + redacted + ); + } else { + log::info!( + "[plugin:{}] keychain read hit: service={}", + pid_read, + service + ); + } + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + + match read_platform_keyring_password(&service, account.as_deref()) { + Ok(value) => { + if let Some(ref redacted) = redacted_account { + log::info!( + "[plugin:{}] keyring read hit: service={}, account={}", + pid_read, + service, + redacted + ); + } else { + log::info!( + "[plugin:{}] keyring read hit: service={}", + pid_read, + service + ); + } + Ok(value) + } + Err(err) => { + if let Some(ref redacted) = redacted_account { + log::warn!( + "[plugin:{}] keyring read miss: service={}, account={}, error={}", + pid_read, + service, + redacted, + err + ); + } else { + log::warn!( + "[plugin:{}] keyring read miss: service={}, error={}", + pid_read, + service, + err + ); + } + Err(Exception::throw_message( + &ctx_inner, + &format!("keychain item not found: {}", err), + )) + } + } + }, + )?, + )?; + + let pid_read_current_user = plugin_id.to_string(); + keychain_obj.set( + "readGenericPasswordForCurrentUser", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, service: String| -> rquickjs::Result { + let account = if cfg!(target_os = "macos") { + current_macos_keychain_account() + } else { + current_macos_keychain_account_from_user_env( + read_env_from_process("USER").or_else(|| read_env_from_process("USERNAME")), + ) + }; let redacted_account = redact_value(&account); log::info!( - "[plugin:{}] keychain write: service={}, account={}", - pid_write_current_user, + "[plugin:{}] keychain read: service={}, account={}", + pid_read_current_user, service, redacted_account ); - match keychain_op_write(&service, Some(&account), &value) { - Ok(()) => { + + if cfg!(target_os = "macos") { + let args = keychain_find_generic_password_args_for_account(&service, &account); + let output = std::process::Command::new("security") + .args(&args) + .output() + .map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("keychain read failed: {}", e), + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + log::warn!( + "[plugin:{}] keychain read miss: service={}, account={}, error={}", + pid_read_current_user, + service, + redacted_account, + first_line + ); + return Err(Exception::throw_message( + &ctx_inner, + &format!("keychain item not found: {}", first_line), + )); + } + + log::info!( + "[plugin:{}] keychain read hit: service={}, account={}", + pid_read_current_user, + service, + redacted_account + ); + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + + match read_platform_keyring_password(&service, Some(&account)) { + Ok(value) => { log::info!( - "[plugin:{}] keychain write succeeded: service={}, account={}", - pid_write_current_user, + "[plugin:{}] keyring read hit: service={}, account={}", + pid_read_current_user, service, redacted_account ); - Ok(()) + Ok(value) } - Err(e) => { + Err(err) => { log::warn!( - "[plugin:{}] keychain write failed: service={}, account={}, error={}", - pid_write_current_user, + "[plugin:{}] keyring read miss: service={}, account={}, error={}", + pid_read_current_user, service, redacted_account, - e + err ); - Err(Exception::throw_message(&ctx_inner, &e)) + Err(Exception::throw_message( + &ctx_inner, + &format!("keychain item not found: {}", err), + )) } } }, )?, )?; - host.set("keychain", keychain_obj)?; - Ok(()) -} - -fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { - let sqlite_obj = Object::new(ctx.clone())?; - - sqlite_obj.set( - "query", + let pid_write_account = plugin_id.to_string(); + keychain_obj.set( + "writeGenericPasswordForAccount", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, db_path: String, sql: String| -> rquickjs::Result { - if sql.lines().any(|line| line.trim_start().starts_with('.')) { + move |ctx_inner: Ctx<'_>, + service: String, + account: String, + value: String| + -> rquickjs::Result<()> { + let account = account.trim().to_string(); + if account.is_empty() { return Err(Exception::throw_message( &ctx_inner, - "sqlite3 dot-commands are not allowed", + "keychain account must not be empty", )); } - let expanded = expand_path(&db_path); + let redacted_account = redact_value(&account); + log::info!( + "[plugin:{}] keychain write: service={}, account={}", + pid_write_account, + service, + redacted_account + ); - // Prefer a normal read-only open so WAL contents are visible (common for app state DBs). - // Fall back to immutable=1 to bypass WAL/SHM lock issues after macOS sleep. - let primary = std::process::Command::new("sqlite3") - .args(["-readonly", "-json", &expanded, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; + if cfg!(target_os = "macos") { + let output = std::process::Command::new("security") + .args(keychain_add_generic_password_args_for_account( + &service, &account, &value, + )) + .output() + .map_err(|e| { + Exception::throw_message( + &ctx_inner, + &format!("keychain write failed: {}", e), + ) + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + return Err(Exception::throw_message( + &ctx_inner, + &format!("keychain write failed: {}", first_line), + )); + } + return Ok(()); + } + + write_platform_keyring_password(&service, Some(&account), &value).map_err(|err| { + Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", err)) + }) + }, + )?, + )?; - if primary.status.success() { - return Ok(String::from_utf8_lossy(&primary.stdout).to_string()); + let pid_write = plugin_id.to_string(); + keychain_obj.set( + "writeGenericPassword", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, service: String, value: String| -> rquickjs::Result<()> { + if !cfg!(target_os = "macos") { + return write_platform_keyring_password(&service, None, &value).map_err( + |err| { + Exception::throw_message( + &ctx_inner, + &format!("keychain write failed: {}", err), + ) + }, + ); } + log::info!("[plugin:{}] keychain write: service={}", pid_write, service); - // Percent-encode special chars for valid URI (% must be first!) - let encoded = expanded - .replace('%', "%25") - .replace(' ', "%20") - .replace('#', "%23") - .replace('?', "%3F"); - let uri_path = format!("file:{}?immutable=1", encoded); - let fallback = std::process::Command::new("sqlite3") - .args(["-readonly", "-json", &uri_path, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; + let mut account_arg: Option = None; + let find_output = std::process::Command::new("security") + .args(["find-generic-password", "-s", &service]) + .output(); + + if let Ok(output) = find_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(start) = line.find("\"acct\"=\"") { + let rest = &line[start + 14..]; + if let Some(end) = rest.find('"') { + account_arg = Some(rest[..end].to_string()); + break; + } + } + } + } + } + + let output = if let Some(ref acct) = account_arg { + std::process::Command::new("security") + .args(keychain_add_generic_password_args_for_account( + &service, acct, &value, + )) + .output() + } else { + std::process::Command::new("security") + .args(keychain_add_generic_password_args(&service, &value)) + .output() + } + .map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) + })?; - if !fallback.status.success() { - let stderr_primary = String::from_utf8_lossy(&primary.stderr); - let stderr_fallback = String::from_utf8_lossy(&fallback.stderr); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + log::warn!( + "[plugin:{}] keychain write failed: service={}, error={}", + pid_write, + service, + first_line + ); return Err(Exception::throw_message( &ctx_inner, - &format!( - "sqlite3 error: {} (fallback: {})", - stderr_primary.trim(), - stderr_fallback.trim() - ), + &format!("keychain write failed: {}", first_line), )); } - Ok(String::from_utf8_lossy(&fallback.stdout).to_string()) + log::info!( + "[plugin:{}] keychain write succeeded: service={}", + pid_write, + service + ); + Ok(()) }, )?, )?; + host.set("keychain", keychain_obj)?; + Ok(()) +} + +fn rusqlite_value_to_json(v: rusqlite::types::Value) -> JsonValue { + match v { + rusqlite::types::Value::Null => JsonValue::Null, + rusqlite::types::Value::Integer(i) => JsonValue::Number(serde_json::Number::from(i)), + rusqlite::types::Value::Real(f) => serde_json::Number::from_f64(f) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null), + rusqlite::types::Value::Text(s) => JsonValue::String(s), + rusqlite::types::Value::Blob(b) => { + JsonValue::String(String::from_utf8_lossy(&b).into_owned()) + } + } +} + +fn sqlite_query_impl(expanded: &str, sql: &str) -> Result { + // Prefer normal read-only open so WAL contents are visible (common for app state DBs). + let conn = match Connection::open_with_flags(expanded, OpenFlags::SQLITE_OPEN_READ_ONLY) { + Ok(c) => c, + Err(e) => { + // Fall back to immutable=1 to bypass WAL/SHM lock issues after macOS sleep. + let encoded = expanded + .replace('%', "%25") + .replace(' ', "%20") + .replace('#', "%23") + .replace('?', "%3F"); + let uri = format!("file:{}?immutable=1", encoded); + Connection::open_with_flags( + &uri, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e2| format!("sqlite open failed: {} (fallback: {})", e, e2))? + } + }; + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + let col_names: Vec = stmt.column_names().into_iter().map(String::from).collect(); + let rows = stmt + .query_map([], |row| { + let mut obj = Map::new(); + for (i, name) in col_names.iter().enumerate() { + let v: rusqlite::types::Value = row + .get(i) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + obj.insert(name.clone(), rusqlite_value_to_json(v)); + } + Ok(JsonValue::Object(obj)) + }) + .map_err(|e| e.to_string())?; + let arr: Result, _> = rows.collect(); + let arr = arr.map_err(|e| e.to_string())?; + serde_json::to_string(&arr).map_err(|e| e.to_string()) +} + +fn sqlite_exec_impl(expanded: &str, sql: &str) -> Result<(), String> { + let conn = Connection::open(expanded).map_err(|e| e.to_string())?; + conn.execute_batch(sql).map_err(|e| e.to_string())?; + Ok(()) +} + +fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { + let sqlite_obj = Object::new(ctx.clone())?; + sqlite_obj.set( - "exec", + "query", Function::new( ctx.clone(), - move |ctx_inner: Ctx<'_>, db_path: String, sql: String| -> rquickjs::Result<()> { + move |ctx_inner: Ctx<'_>, db_path: String, sql: String| -> rquickjs::Result { + reject_path_traversal(&ctx_inner, &db_path)?; if sql.lines().any(|line| line.trim_start().starts_with('.')) { return Err(Exception::throw_message( &ctx_inner, @@ -2760,22 +4042,27 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() )); } let expanded = expand_path(&db_path); - let output = std::process::Command::new("sqlite3") - .args([&expanded, &sql]) - .output() - .map_err(|e| { - Exception::throw_message(&ctx_inner, &format!("sqlite3 exec failed: {}", e)) - })?; + sqlite_query_impl(&expanded, &sql) + .map_err(|e| Exception::throw_message(&ctx_inner, &e)) + }, + )?, + )?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + sqlite_obj.set( + "exec", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, db_path: String, sql: String| -> rquickjs::Result<()> { + reject_path_traversal(&ctx_inner, &db_path)?; + if sql.lines().any(|line| line.trim_start().starts_with('.')) { return Err(Exception::throw_message( &ctx_inner, - &format!("sqlite3 error: {}", stderr.trim()), + "sqlite3 dot-commands are not allowed", )); } - - Ok(()) + let expanded = expand_path(&db_path); + sqlite_exec_impl(&expanded, &sql) + .map_err(|e| Exception::throw_message(&ctx_inner, &e)) }, )?, )?; @@ -2793,14 +4080,28 @@ fn iso_now() -> String { }) } +fn path_has_parent_segment(path: &str) -> bool { + path.split(&['/', '\\']).any(|segment| segment == "..") +} + +fn reject_path_traversal<'js>(ctx: &Ctx<'js>, path: &str) -> rquickjs::Result<()> { + if path_has_parent_segment(path) { + return Err(Exception::throw_message( + ctx, + "path traversal is not allowed", + )); + } + Ok(()) +} + fn expand_path(path: &str) -> String { if path == "~" { - if let Some(home) = dirs::home_dir() { + if let Some(home) = home_dir() { return home.to_string_lossy().to_string(); } } if path.starts_with("~/") { - if let Some(home) = dirs::home_dir() { + if let Some(home) = home_dir() { return home.join(&path[2..]).to_string_lossy().to_string(); } } @@ -2831,12 +4132,27 @@ mod tests { ) } - fn node_generated_aes_256_gcm_vector_for_test() -> (&'static str, &'static str, &'static str) { - ( - "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws=", - "BwcHBwcHBwcHBwcHBwcHBw==:yFbCs4LOJ0aj9NPNf5pfVA==:7PKjtOdATLClvaWrMw0b0M8Nov4KPhxwQX4hdczqQlcZi9Zhi6DjAoK+WolvMwuhPIk=", - r#"{"access_token":"token","refresh_token":"refresh"}"#, - ) + #[test] + fn app_support_path_candidates_cover_linux_macos_windows() { + let paths = app_support_path_candidates("Kiro/User/globalStorage/state.vscdb"); + assert_eq!(paths.len(), 3); + assert!(paths[0].starts_with("~/.config/Kiro/")); + assert!(paths[1].contains("Library/Application Support/Kiro/")); + assert!(paths[2].contains("AppData/Roaming/Kiro/")); + } + + #[test] + fn first_existing_path_returns_first_match() { + let dir = tempfile::tempdir().expect("tempdir"); + let existing = dir.path().join("found.txt"); + std::fs::write(&existing, "ok").expect("write"); + let missing = dir.path().join("missing.txt"); + let paths = vec![ + missing.to_string_lossy().to_string(), + existing.to_string_lossy().to_string(), + ]; + let found = first_existing_path(&paths).expect("found"); + assert_eq!(found, existing.to_string_lossy()); } #[test] @@ -2912,6 +4228,15 @@ mod tests { assert!(tag_err.contains("auth tag length")); } + #[test] + fn path_has_parent_segment_detects_dot_dot() { + assert!(path_has_parent_segment("~/../../etc/passwd")); + assert!(path_has_parent_segment("/tmp/../etc/passwd")); + assert!(!path_has_parent_segment( + "~/.config/Cursor/User/globalStorage/state.vscdb" + )); + } + #[test] fn sanitize_env_value_strips_ansi_and_control_sequences() { let raw = "\u{1b}[?1000l\n sk-test-key-12345\u{1b}[?2004h\r\n"; @@ -2983,7 +4308,8 @@ mod tests { let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); let globals = ctx.globals(); let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); let host: Object = probe_ctx.get("host").expect("host"); @@ -2994,19 +4320,53 @@ mod tests { } #[test] - fn crypto_api_decrypts_node_generated_envelope_from_js() { - let (key_b64, envelope, expected_plaintext) = node_generated_aes_256_gcm_vector_for_test(); + fn keychain_api_exposes_write() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let keychain: Object = host.get("keychain").expect("keychain"); + let _read: Function = keychain + .get("readGenericPassword") + .expect("readGenericPassword"); + let _write: Function = keychain + .get("writeGenericPassword") + .expect("writeGenericPassword"); + }); + } + + #[test] + fn keychain_read_generic_password_accepts_optional_account_arg_from_js() { let rt = Runtime::new().expect("runtime"); let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); - let js_expr = format!( - r#"__openusage_ctx.host.crypto.decryptAes256Gcm("{}", "{}")"#, - envelope, key_b64 + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); + + let message: String = ctx + .eval( + r#" + try { + __openusage_ctx.host.keychain.readGenericPassword("__openusage_missing_service__"); + "ok"; + } catch (e) { + String(e); + } + "#, + ) + .expect("js eval"); + + assert!( + !message.contains("2 where expected"), + "single-arg call should reach the keychain implementation, got: {}", + message ); - let decrypted: String = ctx.eval(js_expr).expect("js decrypt"); - assert_eq!(decrypted, expected_plaintext); }); } @@ -3016,8 +4376,8 @@ mod tests { let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); - // Vector: `printf '%s' 'hello' | shasum -a 256` + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); let result: String = ctx .eval(r#"__openusage_ctx.host.crypto.sha256Hex("hello")"#) .expect("js sha256"); @@ -3025,40 +4385,28 @@ mod tests { result, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); - - let empty: String = ctx - .eval(r#"__openusage_ctx.host.crypto.sha256Hex("")"#) - .expect("js sha256 empty"); - assert_eq!( - empty, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ); }); } #[test] - fn keychain_api_exposes_write_variants() { + fn fireworks_api_exposes_billing_export_helper() { let rt = Runtime::new().expect("runtime"); let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); + patch_fireworks_wrapper(&ctx).expect("patch fireworks wrapper"); let globals = ctx.globals(); let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); let host: Object = probe_ctx.get("host").expect("host"); - let keychain: Object = host.get("keychain").expect("keychain"); - let _read: Function = keychain - .get("readGenericPassword") - .expect("readGenericPassword"); - let _read_current_user: Function = keychain - .get("readGenericPasswordForCurrentUser") - .expect("readGenericPasswordForCurrentUser"); - let _write: Function = keychain - .get("writeGenericPassword") - .expect("writeGenericPassword"); - let _write_current_user: Function = keychain - .get("writeGenericPasswordForCurrentUser") - .expect("writeGenericPasswordForCurrentUser"); + let fireworks: Object = host.get("fireworks").expect("fireworks"); + let _raw: Function = fireworks + .get("_exportBillingMetricsRaw") + .expect("_exportBillingMetricsRaw"); + let _wrapped: Function = fireworks + .get("exportBillingMetrics") + .expect("exportBillingMetrics"); }); } @@ -3081,12 +4429,24 @@ mod tests { "{name} must be whitelisted for Claude auth compatibility" ); } + for name in [ + "OLLAMA_API_KEY", + "OLLAMA_HOST", + "OLLAMA_SESSION_COOKIE", + "OLLAMA_COOKIE", + ] { + assert!( + WHITELISTED_ENV_VARS.contains(&name), + "{name} must be whitelisted for Ollama auth compatibility" + ); + } let rt = Runtime::new().expect("runtime"); let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); let globals = ctx.globals(); let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); let host: Object = probe_ctx.get("host").expect("host"); @@ -3154,7 +4514,8 @@ mod tests { let ctx = Context::full(&rt).expect("context"); ctx.with(|ctx| { let app_data = std::env::temp_dir(); - inject_host_api(&ctx, "test", &app_data, "0.0.0").expect("inject host api"); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); let globals = ctx.globals(); let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); let host: Object = probe_ctx.get("host").expect("host"); @@ -3180,110 +4541,55 @@ mod tests { } #[test] - fn current_macos_keychain_account_prefers_explicit_user_value() { - assert_eq!( - current_macos_keychain_account_from_user_env(Some("openusage-test-user".to_string())), - "openusage-test-user" - ); - } - - #[test] - fn expand_path_expands_tilde_prefix() { - let home = dirs::home_dir().expect("home dir"); - let expected = home.join(".claude-custom").to_string_lossy().to_string(); - - assert_eq!(expand_path("~/.claude-custom"), expected); - } - - #[test] - fn keychain_find_generic_password_args_include_service_only_lookup() { - let args = keychain_find_generic_password_args("Claude Code-credentials"); - let rendered: Vec = args - .into_iter() - .map(|value| value.to_string_lossy().into_owned()) - .collect(); - - assert_eq!( - rendered, - vec![ - "find-generic-password", - "-s", - "Claude Code-credentials", - "-w", - ] - ); - } - - #[test] - fn keychain_find_generic_password_args_for_account_include_account_and_service() { - let args = keychain_find_generic_password_args_for_account( - "Claude Code-credentials", - "openusage-test-user", - ); - let rendered: Vec = args - .into_iter() - .map(|value| value.to_string_lossy().into_owned()) - .collect(); + fn env_api_exposes_fireworks_api_key() { + struct RestoreEnvVar { + name: &'static str, + old: Option, + } - assert_eq!( - rendered, - vec![ - "find-generic-password", - "-a", - "openusage-test-user", - "-s", - "Claude Code-credentials", - "-w", - ] - ); - } + impl Drop for RestoreEnvVar { + fn drop(&mut self) { + if let Some(value) = self.old.take() { + unsafe { std::env::set_var(self.name, value) }; + } else { + unsafe { std::env::remove_var(self.name) }; + } + } + } - #[test] - fn keychain_add_generic_password_args_include_service_only_write() { - let args = keychain_add_generic_password_args("Claude Code-credentials", "secret-value"); - let rendered: Vec = args - .into_iter() - .map(|value| value.to_string_lossy().into_owned()) - .collect(); + let name = "FIREWORKS_API_KEY"; + let old = std::env::var(name).ok(); + let _restore = RestoreEnvVar { name, old }; + unsafe { std::env::set_var(name, "fw-process-env-test-1234567890") }; - assert_eq!( - rendered, - vec![ - "add-generic-password", - "-U", - "-s", - "Claude Code-credentials", - "-w", - "secret-value", - ] - ); - } + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "fireworks", "fireworks", None, &app_data, "0.0.0") + .expect("inject host api"); + let globals = ctx.globals(); + let probe_ctx: Object = globals.get("__openusage_ctx").expect("probe ctx"); + let host: Object = probe_ctx.get("host").expect("host"); + let env: Object = host.get("env").expect("env"); + let get: Function = env.get("get").expect("get"); - #[test] - fn keychain_add_generic_password_args_for_account_include_update_account_service_and_value() { - let args = keychain_add_generic_password_args_for_account( - "Claude Code-credentials", - "openusage-test-user", - "secret-value", - ); - let rendered: Vec = args - .into_iter() - .map(|value| value.to_string_lossy().into_owned()) - .collect(); + let value: Option = get.call((name.to_string(),)).expect("get"); + assert_eq!( + value.as_deref(), + Some("fw-process-env-test-1234567890"), + "fireworks env should be exposed to plugins" + ); - assert_eq!( - rendered, - vec![ - "add-generic-password", - "-U", - "-a", - "openusage-test-user", - "-s", - "Claude Code-credentials", - "-w", - "secret-value", - ] - ); + let js_value: Option = ctx + .eval(r#"__openusage_ctx.host.env.get("FIREWORKS_API_KEY")"#) + .expect("js get"); + assert_eq!( + js_value.as_deref(), + Some("fw-process-env-test-1234567890"), + "fireworks env should be exposed from JS" + ); + }); } #[test] @@ -3338,6 +4644,22 @@ mod tests { ); } + #[test] + fn redact_url_redacts_account_id_path_segment() { + let url = "https://api.fireworks.ai/v1/accounts/acct_1234567890abcdef/quotas?pageSize=200"; + let redacted = redact_url(url); + assert!( + !redacted.contains("acct_1234567890abcdef"), + "account path segment should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("/quotas?pageSize=200"), + "non-sensitive path/query parts should remain visible, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_jwt() { let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; @@ -3357,6 +4679,34 @@ mod tests { assert!(redacted.contains("sk-1...ghij")); } + #[test] + fn redact_body_redacts_email_in_html() { + let body = r#"

person@example.com

"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("person@example.com"), + "email should be redacted from HTML body, got: {}", + redacted + ); + assert!(redacted.contains("pers....com")); + } + + #[test] + fn redact_body_redacts_devin_session_token() { + let body = r#"metadata apiKey=devin-session-token$abcdefghijklmnopqrstuvwxyz123456"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("devin-session-token$abcdefghijklmnopqrstuvwxyz123456"), + "Devin session token should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("devi...3456"), + "Devin session token should use first4...last4 redaction, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_json_password_field() { let body = r#"{"password": "supersecretpassword123"}"#; @@ -3395,6 +4745,22 @@ mod tests { ); } + #[test] + fn redact_body_redacts_profile_arn_fields() { + let body = r#"{"profileArn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK","profile_arn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK"}"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("699475941385"), + "profile arn should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("arn:...QMUK"), + "profile arn should use first4...last4 redaction, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_camel_case_user_and_account_ids() { let body = r#"{"userId": "user_abcdefghijklmnopqrstuvwxyz", "accountId": "acct_1234567890abcdef"}"#; @@ -3421,6 +4787,42 @@ mod tests { ); } + #[test] + fn redact_body_redacts_devin_org_and_account_display_name() { + let body = r#"{"orgId":"org-6b6e9de248db472bb25b296599ea3dc0","accountDisplayName":"rob@sunstory.com","devinInfo":{"org_id":"org-abcdef1234567890","account_display_name":"team@example.com"}}"#; + let redacted = redact_body(body); + assert!( + !redacted.contains("org-6b6e9de248db472bb25b296599ea3dc0"), + "orgId should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "accountDisplayName should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("org-abcdef1234567890"), + "org_id should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("team@example.com"), + "account_display_name should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("org-...3dc0"), + "orgId should show first4...last4, got: {}", + redacted + ); + assert!( + redacted.contains("rob@....com"), + "accountDisplayName should show first4...last4, got: {}", + redacted + ); + } + #[test] fn redact_body_redacts_team_id_payment_id_and_paths() { let body = r#"{"teamId":"cc1ac023-9ff5-4c1f-a5a4-ae2a82df4243","paymentId":"cus_S5m1PGxjLWoc1c","binaryPath":"/opt/homebrew/bin/bunx","homePath":"/Users/rebers/.claude"}"#; @@ -3453,32 +4855,43 @@ mod tests { } #[test] - fn redact_body_redacts_profile_arn_fields() { - let body = r#"{"profileArn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK","profile_arn":"arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK"}"#; - let redacted = redact_body(body); + fn redact_log_message_redacts_jwt_and_api_key() { + let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef"; + let redacted = redact_log_message(msg); assert!( - !redacted.contains("699475941385"), - "profile arn should be redacted, got: {}", - redacted + !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "JWT should be redacted" ); assert!( - redacted.contains("arn:...QMUK"), - "profile arn should use first4...last4 redaction, got: {}", - redacted + !redacted.contains("sk-1234567890abcdef"), + "API key should be redacted" ); } #[test] - fn redact_log_message_redacts_jwt_and_api_key() { - let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef"; + fn redact_log_message_redacts_email() { + let msg = "settings page contained person@example.com"; let redacted = redact_log_message(msg); assert!( - !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), - "JWT should be redacted" + !redacted.contains("person@example.com"), + "email should be redacted" + ); + assert!(redacted.contains("pers....com")); + } + + #[test] + fn redact_log_message_redacts_devin_session_token() { + let msg = "auth=devin-session-token$abcdefghijklmnopqrstuvwxyz123456"; + let redacted = redact_log_message(msg); + assert!( + !redacted.contains("devin-session-token$abcdefghijklmnopqrstuvwxyz123456"), + "Devin session token should be redacted, got: {}", + redacted ); assert!( - !redacted.contains("sk-1234567890abcdef"), - "API key should be redacted" + redacted.contains("devi...3456"), + "Devin session token should use first4...last4 redaction, got: {}", + redacted ); } @@ -3871,8 +5284,6 @@ mod tests { home.join(".bun/bin"), home.join(".nvm/current/bin"), home.join(".local/bin"), - home.join(".cargo/bin"), - home.join(".asdf/shims"), std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), std::path::PathBuf::from("/usr/bin"), @@ -3897,7 +5308,6 @@ mod tests { vec![ std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), - std::path::PathBuf::from("/usr/bin"), std::path::PathBuf::from("/custom/bin"), ] ); @@ -3913,7 +5323,6 @@ mod tests { vec![ std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), - std::path::PathBuf::from("/usr/bin"), ] ); } @@ -3938,8 +5347,6 @@ mod tests { home.join(".bun/bin"), home.join(".nvm/current/bin"), home.join(".local/bin"), - home.join(".cargo/bin"), - home.join(".asdf/shims"), std::path::PathBuf::from("/opt/homebrew/bin"), std::path::PathBuf::from("/usr/local/bin"), std::path::PathBuf::from("/usr/bin"), @@ -4044,21 +5451,29 @@ mod tests { }; assert_eq!( resolve_ccusage_provider(&opts_explicit, "claude"), - CcusageProvider::Codex + Some(CcusageProvider::Codex) ); let opts_empty = CcusageQueryOpts::default(); assert_eq!( resolve_ccusage_provider(&opts_empty, "codex"), - CcusageProvider::Codex + Some(CcusageProvider::Codex) ); assert_eq!( resolve_ccusage_provider(&opts_empty, "claude"), - CcusageProvider::Claude + Some(CcusageProvider::Claude) ); assert_eq!( resolve_ccusage_provider(&opts_empty, "unknown-provider"), - CcusageProvider::Claude + None + ); + assert_eq!( + resolve_ccusage_provider(&opts_empty, "kimi"), + Some(CcusageProvider::Kimi) + ); + assert_eq!( + resolve_ccusage_provider(&opts_empty, "opencode-go"), + Some(CcusageProvider::OpenCode) ); } @@ -4235,12 +5650,12 @@ esac script .write_all(script_body.as_bytes()) .expect("write script"); - let mut permissions = script.metadata().expect("script metadata").permissions(); + drop(script); + let mut permissions = std::fs::metadata(&script_path) + .expect("script metadata") + .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&script_path, permissions).expect("make script executable"); - // Close the file handle before exec: Linux returns ETXTBSY when running - // a file that is still open for writing. - drop(script); let opts = CcusageQueryOpts { provider: Some("codex".to_string()), @@ -4280,57 +5695,17 @@ esac ); } - #[test] - fn probe_deadline_clamps_host_timeout_to_remaining_budget() { - let deadline = ProbeDeadline::at(Instant::now() + Duration::from_millis(25)); - let clamped = deadline - .clamp_duration(Duration::from_secs(10)) - .expect("remaining budget should produce a host timeout"); - - assert!( - clamped <= Duration::from_millis(25), - "host timeout should not exceed remaining probe budget" - ); - assert!( - clamped >= Duration::from_millis(1), - "host timeout should stay non-zero for blocking clients" - ); - } - - #[test] - fn probe_deadline_does_not_extend_elapsed_budget() { - let deadline = ProbeDeadline::at(Instant::now()); - - assert_eq!(deadline.clamp_duration(Duration::from_secs(10)), None); - } - #[cfg(unix)] #[test] fn ccusage_timeout_kills_descendant_and_closes_pipes() { use std::io::Write; use std::os::unix::fs::PermissionsExt; - use std::path::Path; use std::time::{Duration, Instant}; fn pid_exists(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } } - fn read_pid_file(path: &Path, deadline: Instant) -> i32 { - loop { - if let Ok(pid_text) = std::fs::read_to_string(path) { - let pid_text = pid_text.trim(); - if !pid_text.is_empty() { - return pid_text.parse().expect("parse descendant pid"); - } - } - if Instant::now() >= deadline { - panic!("descendant pid file was not created at {}", path.display()); - } - std::thread::sleep(Duration::from_millis(20)); - } - } - let test_id = format!( "openusage-ccusage-timeout-{}", std::time::SystemTime::now() @@ -4356,12 +5731,12 @@ wait script .write_all(script_body.as_bytes()) .expect("write script"); - let mut permissions = script.metadata().expect("script metadata").permissions(); + drop(script); + let mut permissions = std::fs::metadata(&script_path) + .expect("script metadata") + .permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&script_path, permissions).expect("make script executable"); - // Close the file handle before exec: Linux returns ETXTBSY when running - // a file that is still open for writing. - drop(script); let opts = CcusageQueryOpts::default(); let start = Instant::now(); @@ -4372,7 +5747,7 @@ wait CcusageProvider::Codex, "codex", CcusageCommandFlavor::Current, - Duration::from_secs(1), + Duration::from_millis(500), ); assert_eq!(result, CcusageRunnerResult::TimedOut); @@ -4381,7 +5756,16 @@ wait "timeout cleanup should not hang on inherited stdout/stderr pipes" ); - let descendant_pid = read_pid_file(&pid_path, Instant::now() + Duration::from_secs(1)); + let pid_deadline = Instant::now() + Duration::from_secs(2); + while !pid_path.exists() && Instant::now() < pid_deadline { + std::thread::sleep(Duration::from_millis(20)); + } + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("read descendant pid") + .trim() + .parse() + .expect("parse descendant pid"); let deadline = Instant::now() + Duration::from_secs(2); while pid_exists(descendant_pid) && Instant::now() < deadline { diff --git a/src-tauri/src/plugin_engine/manifest.rs b/crates/openusage-core/src/plugin_engine/manifest.rs similarity index 87% rename from src-tauri/src/plugin_engine/manifest.rs rename to crates/openusage-core/src/plugin_engine/manifest.rs index e363b51e6..cd53de331 100644 --- a/src-tauri/src/plugin_engine/manifest.rs +++ b/crates/openusage-core/src/plugin_engine/manifest.rs @@ -1,3 +1,4 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage use base64::{Engine, engine::general_purpose::STANDARD}; use serde::Deserialize; use std::path::{Path, PathBuf}; @@ -42,6 +43,7 @@ pub struct LoadedPlugin { pub plugin_dir: PathBuf, pub entry_script: String, pub icon_data_url: String, + pub icon_file_path: PathBuf, } pub fn load_plugins_from_dir(plugins_dir: &std::path::Path) -> Vec { @@ -108,18 +110,32 @@ fn load_single_plugin( let entry_script = std::fs::read_to_string(&canonical_entry_path)?; - let icon_file = plugin_dir.join(&manifest.icon); - let icon_bytes = std::fs::read(&icon_file)?; - let icon_data_url = format!("data:image/svg+xml;base64,{}", STANDARD.encode(&icon_bytes)); + let icon_file_path = plugin_dir.join(&manifest.icon).canonicalize()?; + if !icon_file_path.starts_with(&canonical_plugin_dir) { + return Err("plugin icon must remain within plugin directory".into()); + } + let icon_bytes = std::fs::read(&icon_file_path)?; + let icon_data_url = icon_data_url_for_file(&icon_file_path, &icon_bytes); Ok(LoadedPlugin { manifest, plugin_dir: plugin_dir.to_path_buf(), entry_script, icon_data_url, + icon_file_path, }) } +fn icon_data_url_for_file(path: &Path, bytes: &[u8]) -> String { + let mime = match path.extension().and_then(|ext| ext.to_str()) { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + _ => "image/svg+xml", + }; + format!("data:{mime};base64,{}", STANDARD.encode(bytes)) +} + fn sanitize_plugin_links(plugin_id: &str, links: Vec) -> Vec { links .into_iter() @@ -268,6 +284,18 @@ mod tests { assert_eq!(manifest.links[1].url, "https://example.com/billing"); } + #[test] + fn icon_data_url_uses_png_mime_for_png_files() { + let mime = icon_data_url_for_file(Path::new("icon.png"), b"\x89PNG"); + assert!(mime.starts_with("data:image/png;base64,")); + } + + #[test] + fn icon_data_url_uses_svg_mime_for_svg_files() { + let mime = icon_data_url_for_file(Path::new("icon.svg"), b", +) -> (PathBuf, Vec) { + if let Some(dev_dir) = find_dev_plugins_dir() { + if !is_dir_empty(&dev_dir) { + let plugins = load_active_plugins_from_dir(&dev_dir); + return (dev_dir, plugins); + } + } + + let install_dir = app_data_dir.join("plugins"); + if let Err(err) = std::fs::create_dir_all(&install_dir) { + log::warn!( + "failed to create install dir {}: {}", + install_dir.display(), + err + ); + } + + if let Some(res) = resource_dir { + let bundled_dir = resolve_bundled_dir(res); + if bundled_dir.exists() { + let new_plugins = list_missing_plugin_dirs(&bundled_dir, &install_dir); + copy_dir_recursive(&bundled_dir, &install_dir); + remove_retired_bundled_plugins(&install_dir); + if !new_plugins.is_empty() { + log::info!( + "synced {} new bundled plugin(s) into {}: {}", + new_plugins.len(), + install_dir.display(), + new_plugins.join(", ") + ); + } + } else { + log::warn!("bundled plugins dir missing at {}", bundled_dir.display()); + } + } + + let plugins = load_active_plugins_from_dir(&install_dir); + (install_dir, plugins) +} + +fn load_active_plugins_from_dir(plugins_dir: &Path) -> Vec { + manifest::load_plugins_from_dir(plugins_dir) + .into_iter() + .filter(|plugin| !is_retired_bundled_plugin_id(&plugin.manifest.id)) + .collect() +} + +fn is_retired_bundled_plugin_id(id: &str) -> bool { + RETIRED_BUNDLED_PLUGIN_IDS.contains(&id) +} + +fn find_dev_plugins_dir() -> Option { + let cwd = std::env::current_dir().ok()?; + let direct = cwd.join("plugins"); + if direct.exists() { + return Some(direct); + } + let parent = cwd.join("..").join("plugins"); + if parent.exists() { + return Some(parent); + } + None +} + +fn resolve_bundled_dir(resource_dir: &Path) -> PathBuf { + let nested = resource_dir.join("resources/bundled_plugins"); + if nested.exists() { + nested + } else { + resource_dir.join("bundled_plugins") + } +} + +fn is_dir_empty(path: &Path) -> bool { + match std::fs::read_dir(path) { + Ok(mut entries) => entries.next().is_none(), + Err(err) => { + log::warn!("failed to read dir {}: {}", path.display(), err); + true + } + } +} + +fn remove_retired_bundled_plugins(install_dir: &Path) { + for id in RETIRED_BUNDLED_PLUGIN_IDS { + let plugin_dir = install_dir.join(id); + if !plugin_dir.is_dir() || !plugin_dir_has_id(&plugin_dir, id) { + continue; + } + + if let Err(err) = std::fs::remove_dir_all(&plugin_dir) { + log::warn!( + "failed to remove retired bundled plugin {}: {}", + plugin_dir.display(), + err + ); + } + } +} + +fn plugin_dir_has_id(plugin_dir: &Path, expected_id: &str) -> bool { + let manifest_path = plugin_dir.join("plugin.json"); + let Ok(text) = std::fs::read_to_string(&manifest_path) else { + return false; + }; + let Ok(value) = serde_json::from_str::(&text) else { + return false; + }; + value + .get("id") + .and_then(|id| id.as_str()) + .is_some_and(|id| id == expected_id) +} + +fn list_missing_plugin_dirs(bundled_dir: &Path, install_dir: &Path) -> Vec { + let entries = match std::fs::read_dir(bundled_dir) { + Ok(entries) => entries, + Err(err) => { + log::warn!( + "failed to read bundled plugins dir {}: {}", + bundled_dir.display(), + err + ); + return Vec::new(); + } + }; + + let mut missing = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if !path.join("plugin.json").is_file() { + continue; + } + let dst = install_dir.join(entry.file_name()); + if !dst.exists() { + missing.push(entry.file_name().to_string_lossy().into_owned()); + } + } + missing.sort(); + missing +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + let entries = match std::fs::read_dir(src) { + Ok(entries) => entries, + Err(err) => { + log::warn!("failed to read dir {}: {}", src.display(), err); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!("failed to read entry in {}: {}", src.display(), err); + continue; + } + }; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log::warn!( + "failed to read file type for {}: {}", + src_path.display(), + err + ); + continue; + } + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + if let Err(err) = std::fs::create_dir_all(&dst_path) { + log::warn!("failed to create dir {}: {}", dst_path.display(), err); + continue; + } + copy_dir_recursive(&src_path, &dst_path); + } else if file_type.is_file() { + if let Err(err) = std::fs::copy(&src_path, &dst_path) { + log::warn!( + "failed to copy {} to {}: {}", + src_path.display(), + dst_path.display(), + err + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new(name: &str) -> Self { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "openusage-plugin-engine-{}-{}-{}", + name, + std::process::id(), + suffix + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + struct CurrentDirGuard { + original: PathBuf, + } + + impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let original = std::env::current_dir().expect("read current dir"); + std::env::set_current_dir(path).expect("set current dir"); + Self { original } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_plugin(parent: &Path, id: &str, name: &str) { + let plugin_dir = parent.join(id); + write_plugin_at(&plugin_dir, id, name); + } + + fn write_plugin_at(plugin_dir: &Path, id: &str, name: &str) { + fs::create_dir_all(plugin_dir).expect("create plugin dir"); + fs::write( + plugin_dir.join("plugin.json"), + format!( + r##"{{ + "schemaVersion": 1, + "id": "{}", + "name": "{}", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "lines": [] +}}"##, + id, name + ), + ) + .expect("write plugin manifest"); + fs::write( + plugin_dir.join("plugin.js"), + format!( + r#"globalThis.__openusage_plugin = {{ id: "{}", probe: () => ({{ lines: [] }}) }}"#, + id + ), + ) + .expect("write plugin script"); + fs::write( + plugin_dir.join("icon.svg"), + r#""#, + ) + .expect("write plugin icon"); + } + + #[test] + #[serial] + fn initialize_plugins_removes_retired_windsurf_without_removing_custom_plugins() { + let root = TempDir::new("retired"); + let _cwd = CurrentDirGuard::enter(root.path()); + let app_data_dir = root.path().join("app-data"); + let install_dir = app_data_dir.join("plugins"); + let resource_dir = root.path().join("resources"); + let bundled_dir = resource_dir.join("bundled_plugins"); + + write_plugin(&install_dir, "windsurf", "Windsurf"); + write_plugin(&install_dir, "custom", "Custom"); + write_plugin(&bundled_dir, "devin", "Devin"); + + let (loaded_dir, plugins) = initialize_plugins(&app_data_dir, Some(&resource_dir)); + let ids: Vec<_> = plugins + .iter() + .map(|plugin| plugin.manifest.id.as_str()) + .collect(); + + assert_eq!(loaded_dir, install_dir); + assert!(!loaded_dir.join("windsurf").exists()); + assert!(loaded_dir.join("custom").exists()); + assert!(loaded_dir.join("devin").exists()); + assert_eq!(ids, vec!["custom", "devin"]); + } + + #[test] + #[serial] + fn initialize_plugins_skips_retired_plugin_even_when_cleanup_does_not_remove_it() { + let root = TempDir::new("retired-skip"); + let _cwd = CurrentDirGuard::enter(root.path()); + let app_data_dir = root.path().join("app-data"); + let install_dir = app_data_dir.join("plugins"); + let resource_dir = root.path().join("resources"); + fs::create_dir_all(&resource_dir).expect("create resource dir"); + + let mismatched_dir = install_dir.join("legacy-name"); + write_plugin_at(&mismatched_dir, "windsurf", "Windsurf"); + + let (_loaded_dir, plugins) = initialize_plugins(&app_data_dir, Some(&resource_dir)); + + assert!(mismatched_dir.exists()); + assert!(plugins.is_empty()); + } +} diff --git a/src-tauri/src/plugin_engine/runtime.rs b/crates/openusage-core/src/plugin_engine/runtime.rs similarity index 56% rename from src-tauri/src/plugin_engine/runtime.rs rename to crates/openusage-core/src/plugin_engine/runtime.rs index 3608b3cac..34969ae73 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/crates/openusage-core/src/plugin_engine/runtime.rs @@ -1,5 +1,7 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage use crate::plugin_engine::host_api; use crate::plugin_engine::manifest::LoadedPlugin; +use crate::provider_accounts::ProviderAccountContext; use rquickjs::{Array, Context, Ctx, Error, Object, Promise, Runtime, Value}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -15,6 +17,24 @@ pub enum ProgressFormat { Count { suffix: String }, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BarChartPoint { + label: String, + value: f64, + #[serde(rename = "valueLabel")] + value_label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelSpendBreakdown { + pub model: String, + pub tokens: u64, + pub cost_usd: Option, + pub percent: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum MetricLine { @@ -23,6 +43,9 @@ pub enum MetricLine { value: String, color: Option, subtitle: Option, + model_breakdown: Option>, + status_dot: Option, + expiry_tooltip: Option, }, Progress { label: String, @@ -41,6 +64,13 @@ pub enum MetricLine { color: Option, subtitle: Option, }, + #[serde(rename = "barChart")] + BarChart { + label: String, + points: Vec, + note: Option, + color: Option, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -49,27 +79,40 @@ pub struct PluginOutput { pub provider_id: String, pub display_name: String, pub plan: Option, + #[serde(default)] + pub warning: Option, pub lines: Vec, pub icon_url: String, } pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str) -> PluginOutput { - run_probe_with_timeout( + run_probe_with_account(plugin, app_data_dir, app_version, None) +} + +pub fn run_probe_with_account( + plugin: &LoadedPlugin, + app_data_dir: &PathBuf, + app_version: &str, + account: Option, +) -> PluginOutput { + run_probe_with_account_timeout( plugin, app_data_dir, app_version, + account, Duration::from_secs(PROBE_TIMEOUT_SECS), ) } -fn run_probe_with_timeout( +fn run_probe_with_account_timeout( plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str, + account: Option, timeout: Duration, ) -> PluginOutput { let fallback = error_output(plugin, "runtime error".to_string()); - let timeout_message = probe_timeout_message(timeout); + let timeout_message = format!("probe timed out after {}s", timeout.as_secs()); let deadline_at = Instant::now() .checked_add(timeout) .unwrap_or_else(Instant::now); @@ -86,8 +129,19 @@ fn run_probe_with_timeout( Err(_) => return fallback, }; - let plugin_id = plugin.manifest.id.clone(); - let display_name = plugin.manifest.name.clone(); + let plugin_id = account + .as_ref() + .map(|account| account.instance_id.clone()) + .unwrap_or_else(|| plugin.manifest.id.clone()); + let base_plugin_id = account + .as_ref() + .map(|account| account.base_provider_id.clone()) + .unwrap_or_else(|| plugin.manifest.id.clone()); + let display_name = account + .as_ref() + .filter(|account| !account.label.trim().is_empty()) + .map(|account| format!("{} ({})", plugin.manifest.name, account.label.trim())) + .unwrap_or_else(|| plugin.manifest.name.clone()); let entry_script = plugin.entry_script.clone(); let icon_url = plugin.icon_data_url.clone(); let app_data = app_data_dir.clone(); @@ -95,7 +149,9 @@ fn run_probe_with_timeout( ctx.with(|ctx| { if host_api::inject_host_api_with_deadline( &ctx, + &base_plugin_id, &plugin_id, + account.as_ref(), &app_data, app_version, deadline, @@ -125,6 +181,42 @@ fn run_probe_with_timeout( } return error_output(plugin, "ccusage wrapper patch failed".to_string()); } + if host_api::patch_usage_daily_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "usageDaily wrapper patch failed".to_string()); + } + if host_api::patch_cursor_logs_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "cursorLogs wrapper patch failed".to_string()); + } + if host_api::patch_claude_logs_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "claudeLogs wrapper patch failed".to_string()); + } + if host_api::patch_codex_logs_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "codexLogs wrapper patch failed".to_string()); + } + if host_api::patch_cursor_usage_export_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "cursorUsageExport wrapper patch failed".to_string()); + } + if host_api::patch_fireworks_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "fireworks wrapper patch failed".to_string()); + } if host_api::inject_utils(&ctx).is_err() { if deadline.has_elapsed() { return error_output(plugin, timeout_message.clone()); @@ -178,12 +270,7 @@ fn run_probe_with_timeout( Err(Error::WouldBlock) => { return error_output(plugin, "probe() returned unresolved promise".to_string()); } - Err(_) => { - if deadline.has_elapsed() { - return error_output(plugin, timeout_message.clone()); - } - return error_output(plugin, extract_error_string(&ctx)); - } + Err(_) => return error_output(plugin, extract_error_string(&ctx)), } } else { match result_value.into_object() { @@ -191,15 +278,17 @@ fn run_probe_with_timeout( None => return error_output(plugin, "probe() returned non-object".to_string()), } }; - if deadline.has_elapsed() { - return error_output(plugin, timeout_message.clone()); - } let plan: Option = result .get::<_, String>("plan") .ok() .filter(|s| !s.is_empty()); + let warning: Option = result + .get::<_, String>("warning") + .ok() + .filter(|s| !s.is_empty()); + let lines = match parse_lines(&result) { Ok(lines) if !lines.is_empty() => lines, Ok(_) => vec![error_line("no lines returned".to_string())], @@ -210,12 +299,45 @@ fn run_probe_with_timeout( provider_id: plugin_id, display_name, plan, + warning, lines, icon_url, } }) } +fn parse_model_breakdown(line: &Object) -> Option> { + let arr: Array = line.get("modelBreakdown").ok()?; + let mut out = Vec::new(); + let len = arr.len(); + for idx in 0..len { + let row: Object = arr.get(idx).ok()?; + let model = row.get::<_, String>("model").unwrap_or_default(); + let tokens = row + .get::<_, f64>("tokens") + .ok() + .filter(|v| v.is_finite()) + .map(|v| v.max(0.0) as u64) + .unwrap_or(0); + let percent = row + .get::<_, f64>("percent") + .ok() + .filter(|v| v.is_finite()) + .unwrap_or(0.0); + let cost_usd = row.get::<_, f64>("costUsd").ok().filter(|v| v.is_finite()); + if model.trim().is_empty() { + continue; + } + out.push(ModelSpendBreakdown { + model, + tokens, + cost_usd, + percent, + }); + } + if out.is_empty() { None } else { Some(out) } +} + fn parse_lines(result: &Object) -> Result, String> { let lines: Array = result .get("lines") @@ -236,11 +358,17 @@ fn parse_lines(result: &Object) -> Result, String> { match line_type.as_str() { "text" => { let value = line.get::<_, String>("value").unwrap_or_default(); + let model_breakdown = parse_model_breakdown(&line); + let status_dot = line.get::<_, String>("statusDot").ok(); + let expiry_tooltip = line.get::<_, String>("expiryTooltip").ok(); out.push(MetricLine::Text { label, value, color, subtitle, + model_breakdown, + status_dot, + expiry_tooltip, }); } "progress" => { @@ -487,6 +615,15 @@ fn parse_lines(result: &Object) -> Result, String> { subtitle, }); } + "barChart" => { + let (chart, errors) = parse_bar_chart_line(&line, idx, label, color); + for message in errors { + out.push(error_line(message)); + } + if let Some(chart) = chart { + out.push(chart); + } + } _ => { out.push(error_line(format!( "unknown line type at index {}: {}", @@ -499,11 +636,180 @@ fn parse_lines(result: &Object) -> Result, String> { Ok(out) } +// Upper bound on barChart points parsed from a plugin. The chart is daily +// history (plugins emit ~31), so a year of points is generous headroom while +// keeping the loop and allocations bounded — parse_lines runs natively after +// the JS returns, so the probe's interrupt-based timeout can't cap it here. +const MAX_BAR_CHART_POINTS: usize = 366; + +// Parses a barChart line, keeping its point/value/note validation out of +// parse_lines. Returns the built line (when at least one point is valid) plus +// any per-point error messages the caller should surface as error lines. +fn parse_bar_chart_line<'js>( + line: &Object<'js>, + idx: usize, + label: String, + color: Option, +) -> (Option, Vec) { + let mut errors: Vec = Vec::new(); + + let points_array: Array = match line.get("points") { + Ok(points) => points, + Err(_) => { + errors.push(format!("barChart line at index {} missing points", idx)); + return (None, errors); + } + }; + + // Bound the loop to a plugin-independent maximum so a huge points array + // can't exhaust CPU/memory in this native (non-interruptible) path. + let total_points = points_array.len(); + let scan_count = total_points.min(MAX_BAR_CHART_POINTS); + if total_points > MAX_BAR_CHART_POINTS { + log::warn!( + "barChart line at index {} has {} points; capping at {}", + idx, + total_points, + MAX_BAR_CHART_POINTS + ); + } + + let mut points = Vec::new(); + for point_idx in 0..scan_count { + let point: Object = match points_array.get(point_idx) { + Ok(point) => point, + Err(_) => { + errors.push(format!( + "barChart line at index {} has invalid point at index {}", + idx, point_idx + )); + continue; + } + }; + let point_label = point.get::<_, String>("label").unwrap_or_default(); + let point_label = point_label.trim().to_string(); + if point_label.is_empty() { + errors.push(format!( + "barChart line at index {} has empty point label at index {}", + idx, point_idx + )); + continue; + } + + let value: Value = match point.get("value") { + Ok(v) => v, + Err(_) => { + errors.push(format!( + "barChart line at index {} point {} missing value", + idx, point_idx + )); + continue; + } + }; + let value = match value.as_number() { + Some(n) if n.is_finite() && n >= 0.0 => n, + _ => { + errors.push(format!( + "barChart line at index {} point {} invalid value", + idx, point_idx + )); + continue; + } + }; + + let value_label = match point.get::<_, Value>("valueLabel") { + Ok(v) => { + if v.is_null() || v.is_undefined() { + None + } else if let Some(s) = v.as_string() { + let value = s.to_string().unwrap_or_default(); + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } else { + log::warn!( + "invalid barChart valueLabel at line {} point {}, omitting", + idx, + point_idx + ); + None + } + } + Err(_) => None, + }; + + points.push(BarChartPoint { + label: point_label, + value, + value_label, + }); + } + + if points.is_empty() { + errors.push(format!( + "barChart line at index {} has no valid points", + idx + )); + return (None, errors); + } + + let note = match line.get::<_, Value>("note") { + Ok(v) => { + if v.is_null() || v.is_undefined() { + None + } else if let Some(s) = v.as_string() { + let value = s.to_string().unwrap_or_default(); + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + } else { + log::warn!("invalid note at index {} (non-string), omitting", idx); + None + } + } + Err(_) => None, + }; + + ( + Some(MetricLine::BarChart { + label, + points, + note, + color, + }), + errors, + ) +} + fn error_output(plugin: &LoadedPlugin, message: String) -> PluginOutput { PluginOutput { provider_id: plugin.manifest.id.clone(), display_name: plugin.manifest.name.clone(), plan: None, + warning: None, + lines: vec![error_line(message)], + icon_url: plugin.icon_data_url.clone(), + } +} + +/// Error-shaped probe output for a specific provider instance (matches `run_probe_with_account` ids). +pub fn probe_fault_output( + plugin: &LoadedPlugin, + provider_id: &str, + display_name: &str, + message: String, +) -> PluginOutput { + PluginOutput { + provider_id: provider_id.to_string(), + display_name: display_name.to_string(), + plan: None, + warning: None, lines: vec![error_line(message)], icon_url: plugin.icon_data_url.clone(), } @@ -524,16 +830,6 @@ fn extract_error_string(ctx: &Ctx<'_>) -> String { "The plugin failed, try again or contact plugin author.".to_string() } -fn probe_timeout_message(timeout: Duration) -> String { - if timeout.subsec_millis() == 0 { - return format!("probe timed out after {}s", timeout.as_secs()); - } - if timeout.as_secs() == 0 { - return format!("probe timed out after {}ms", timeout.as_millis()); - } - format!("probe timed out after {:.3}s", timeout.as_secs_f64()) -} - fn error_line(message: String) -> MetricLine { MetricLine::Badge { label: "Error".to_string(), @@ -546,17 +842,23 @@ fn error_line(message: String) -> MetricLine { #[cfg(test)] mod tests { use super::*; + use crate::plugin_engine::host_api; use crate::plugin_engine::manifest::{LoadedPlugin, PluginManifest}; + use crate::provider_accounts::{ProviderAccountContext, ProviderCredential}; use serde_json::Value as JsonValue; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; fn test_plugin(entry_script: &str) -> LoadedPlugin { + test_plugin_with_id("test", "Test", entry_script) + } + + fn test_plugin_with_id(id: &str, name: &str, entry_script: &str) -> LoadedPlugin { LoadedPlugin { manifest: PluginManifest { schema_version: 1, - id: "test".to_string(), - name: "Test".to_string(), + id: id.to_string(), + name: name.to_string(), version: "0.0.0".to_string(), entry: "plugin.js".to_string(), icon: "icon.svg".to_string(), @@ -567,6 +869,7 @@ mod tests { plugin_dir: PathBuf::from("."), entry_script: entry_script.to_string(), icon_data_url: "data:image/svg+xml;base64,".to_string(), + icon_file_path: PathBuf::from("."), } } @@ -585,6 +888,26 @@ mod tests { } } + fn account( + instance_id: &str, + base_provider_id: &str, + label: &str, + token: &str, + ) -> ProviderAccountContext { + ProviderAccountContext { + instance_id: instance_id.to_string(), + base_provider_id: base_provider_id.to_string(), + label: label.to_string(), + credential: Some(ProviderCredential { + access_token: Some(token.to_string()), + refresh_token: None, + session_key: None, + expires_at: None, + }), + store_path: None, + } + } + #[test] fn run_probe_returns_thrown_string_from_sync_error() { let plugin = test_plugin( @@ -615,28 +938,6 @@ mod tests { assert_eq!(error_text(output), "boom"); } - #[test] - fn run_probe_times_out_cpu_bound_script() { - let plugin = test_plugin( - r#" - globalThis.__openusage_plugin = { - probe() { - while (true) {} - } - }; - "#, - ); - - let output = run_probe_with_timeout( - &plugin, - &temp_app_dir("timeout"), - "0.0.0", - Duration::from_millis(5), - ); - - assert_eq!(error_text(output), "probe timed out after 5ms"); - } - #[test] fn progress_resets_at_serializes_as_resets_at_camelcase() { let line = MetricLine::Progress { @@ -657,4 +958,159 @@ mod tests { "did not expect resets_at key" ); } + + #[test] + fn bar_chart_line_round_trips_from_builder() { + let plugin = test_plugin( + r#" + globalThis.__openusage_plugin = { + probe(ctx) { + return { + lines: [ + ctx.line.barChart({ + label: "Usage Trend", + points: [{ label: "Today", value: 42, valueLabel: "42 tokens" }], + note: "Estimated from local logs" + }) + ] + }; + } + }; + "#, + ); + + let output = run_probe(&plugin, &temp_app_dir("bar-chart"), "0.0.0"); + let json: JsonValue = serde_json::to_value(&output.lines[0]).expect("serialize"); + assert_eq!(json["type"], "barChart"); + assert_eq!(json["label"], "Usage Trend"); + assert_eq!(json["points"][0]["valueLabel"], "42 tokens"); + assert_eq!(json["note"], "Estimated from local logs"); + } + + #[test] + fn bar_chart_caps_excessive_points() { + let plugin = test_plugin( + r#" + globalThis.__openusage_plugin = { + probe(ctx) { + var points = []; + for (var i = 0; i < 5000; i++) { + points.push({ label: "d" + i, value: i }); + } + return { lines: [ctx.line.barChart({ label: "Big", points: points })] }; + } + }; + "#, + ); + + let output = run_probe(&plugin, &temp_app_dir("bar-chart-cap"), "0.0.0"); + let json: JsonValue = serde_json::to_value(&output.lines[0]).expect("serialize"); + assert_eq!(json["type"], "barChart"); + assert_eq!( + json["points"].as_array().expect("points array").len(), + MAX_BAR_CHART_POINTS + ); + } + + #[test] + fn run_probe_routes_account_credentials_to_http_headers() { + let claude = test_plugin_with_id( + "claude", + "Claude", + r#" + globalThis.__openusage_plugin = { + probe(ctx) { + const raw = ctx.host.credentials.get(); + const credential = JSON.parse(raw); + ctx.host.http.request({ + method: "GET", + url: "https://example.test/claude/" + ctx.host.account.instanceId, + headers: { Authorization: "Bearer " + credential.accessToken } + }); + return { lines: [{ type: "text", label: "Account", value: ctx.host.account.label }] }; + } + }; + "#, + ); + let cursor = test_plugin_with_id( + "cursor", + "Cursor", + r#" + globalThis.__openusage_plugin = { + probe(ctx) { + const raw = ctx.host.credentials.get(); + const credential = JSON.parse(raw); + ctx.host.http.request({ + method: "POST", + url: "https://example.test/cursor", + headers: { + Authorization: "Bearer " + credential.accessToken, + Cookie: "WorkosCursorSessionToken=test-session" + } + }); + return { lines: [{ type: "text", label: "Account", value: ctx.account.label }] }; + } + }; + "#, + ); + host_api::install_http_mock(vec![(200, "{}"), (200, "{}"), (200, "{}")]); + + let dir = temp_app_dir("multi-account"); + let out_work = run_probe_with_account( + &claude, + &dir, + "0.0.0", + Some(account( + "claude:work", + "claude", + "Work", + "claude-work-token", + )), + ); + let out_personal = run_probe_with_account( + &claude, + &dir, + "0.0.0", + Some(account( + "claude:personal", + "claude", + "Personal", + "claude-personal-token", + )), + ); + let out_cursor = run_probe_with_account( + &cursor, + &dir, + "0.0.0", + Some(account( + "cursor:default", + "cursor", + "Default", + "cursor-token", + )), + ); + + assert_eq!(out_work.provider_id, "claude:work"); + assert_eq!(out_personal.provider_id, "claude:personal"); + assert_eq!(out_cursor.provider_id, "cursor:default"); + + let requests = host_api::take_http_mock_requests(); + assert_eq!(requests.len(), 3); + assert_eq!( + requests[0].headers.get("Authorization").map(String::as_str), + Some("Bearer claude-work-token") + ); + assert_eq!( + requests[1].headers.get("Authorization").map(String::as_str), + Some("Bearer claude-personal-token") + ); + assert_eq!( + requests[2].headers.get("Authorization").map(String::as_str), + Some("Bearer cursor-token") + ); + assert_eq!( + requests[2].headers.get("Cookie").map(String::as_str), + Some("WorkosCursorSessionToken=test-session") + ); + } } diff --git a/crates/openusage-core/src/provider_accounts.rs b/crates/openusage-core/src/provider_accounts.rs new file mode 100644 index 000000000..2637447bd --- /dev/null +++ b/crates/openusage-core/src/provider_accounts.rs @@ -0,0 +1,360 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +use crate::provider_accounts_crypto::{self, EncryptedProviderAccountsFile, STORE_FORMAT_V1}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const STORE_FILE: &str = "provider_accounts.json"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCredential { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +impl ProviderCredential { + pub fn is_empty(&self) -> bool { + self.access_token.as_deref().unwrap_or("").trim().is_empty() + && self + .refresh_token + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + && self.session_key.as_deref().unwrap_or("").trim().is_empty() + && self.expires_at.is_none() + } + + pub fn merge_update(&mut self, update: ProviderCredential) { + if let Some(value) = update.access_token { + self.access_token = normalize_secret(value); + } + if let Some(value) = update.refresh_token { + self.refresh_token = normalize_secret(value); + } + if let Some(value) = update.session_key { + self.session_key = normalize_secret(value); + } + if update.expires_at.is_some() { + self.expires_at = update.expires_at; + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAccount { + pub instance_id: String, + pub base_provider_id: String, + pub label: String, + #[serde(default)] + pub credential: ProviderCredential, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ProviderAccountsStore { + #[serde(default)] + pub accounts: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct ProviderAccountContext { + pub instance_id: String, + pub base_provider_id: String, + pub label: String, + pub credential: Option, + pub store_path: Option, +} + +pub fn store_path(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(STORE_FILE) +} + +pub fn load_store(app_data_dir: &Path) -> io::Result { + load_store_path(&store_path(app_data_dir)) +} + +pub fn load_store_path(path: &Path) -> io::Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(ProviderAccountsStore::default()); + } + Err(err) => return Err(err), + }; + + let app_data_dir = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid provider accounts path", + ) + })?; + let (store, migrated_from_legacy) = decode_store_text(&text, app_data_dir)?; + if migrated_from_legacy { + save_store_path(path, &store)?; + } + Ok(store) +} + +fn decode_store_text(text: &str, app_data_dir: &Path) -> io::Result<(ProviderAccountsStore, bool)> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Ok((ProviderAccountsStore::default(), false)); + } + + if let Ok(wrapper) = serde_json::from_str::(trimmed) { + if wrapper.format == STORE_FORMAT_V1 { + let plaintext = + provider_accounts_crypto::decrypt_store_envelope(&wrapper.envelope, app_data_dir)?; + let store = serde_json::from_str(&plaintext).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid decrypted provider accounts store: {err}"), + ) + })?; + return Ok((store, false)); + } + } + + if provider_accounts_crypto::is_legacy_plaintext_store(trimmed) { + let store = serde_json::from_str(trimmed).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid legacy provider accounts store: {err}"), + ) + })?; + return Ok((store, true)); + } + + Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid provider accounts store", + )) +} + +pub fn save_store(app_data_dir: &Path, store: &ProviderAccountsStore) -> io::Result<()> { + save_store_path(&store_path(app_data_dir), store) +} + +pub fn save_store_path(path: &Path, store: &ProviderAccountsStore) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let app_data_dir = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid provider accounts path", + ) + })?; + let plaintext = serde_json::to_string(store).map_err(io::Error::other)?; + let encrypted = provider_accounts_crypto::encrypt_store_plaintext(&plaintext, app_data_dir)?; + let text = serde_json::to_string_pretty(&encrypted).map_err(io::Error::other)?; + fs::write(path, text)?; + restrict_private_file_permissions(path) +} + +#[cfg(unix)] +fn restrict_private_file_permissions(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn restrict_private_file_permissions(_path: &Path) -> io::Result<()> { + Ok(()) +} + +pub fn get_account(app_data_dir: &Path, instance_id: &str) -> io::Result> { + Ok(load_store(app_data_dir)?.accounts.get(instance_id).cloned()) +} + +pub fn upsert_account(app_data_dir: &Path, mut account: ProviderAccount) -> io::Result<()> { + account.instance_id = account.instance_id.trim().to_string(); + account.base_provider_id = account.base_provider_id.trim().to_string(); + account.label = account.label.trim().to_string(); + account.credential.access_token = account.credential.access_token.and_then(normalize_secret); + account.credential.refresh_token = account.credential.refresh_token.and_then(normalize_secret); + account.credential.session_key = account.credential.session_key.and_then(normalize_secret); + + let mut store = load_store(app_data_dir)?; + store.accounts.insert(account.instance_id.clone(), account); + save_store(app_data_dir, &store) +} + +pub fn delete_account(app_data_dir: &Path, instance_id: &str) -> io::Result<()> { + let mut store = load_store(app_data_dir)?; + store.accounts.remove(instance_id); + save_store(app_data_dir, &store) +} + +pub fn update_credential_at_path( + store_path: &Path, + instance_id: &str, + update: ProviderCredential, +) -> io::Result> { + let mut store = load_store_path(store_path)?; + let Some(account) = store.accounts.get_mut(instance_id) else { + return Ok(None); + }; + account.credential.merge_update(update); + let credential = account.credential.clone(); + save_store_path(store_path, &store)?; + Ok(Some(credential)) +} + +fn normalize_secret(value: String) -> Option { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!("openusage-provider-accounts-{name}-{nonce}")); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + #[serial] + fn upsert_load_and_update_credentials() { + provider_accounts_crypto::set_test_master_key(Some([7_u8; 32])); + let dir = temp_dir("roundtrip"); + upsert_account( + &dir, + ProviderAccount { + instance_id: "claude:work".into(), + base_provider_id: "claude".into(), + label: "Work".into(), + credential: ProviderCredential { + access_token: Some("old".into()), + refresh_token: None, + session_key: None, + expires_at: None, + }, + }, + ) + .unwrap(); + + let path = store_path(&dir); + let updated = update_credential_at_path( + &path, + "claude:work", + ProviderCredential { + access_token: Some("new".into()), + refresh_token: Some("refresh".into()), + session_key: None, + expires_at: Some(123), + }, + ) + .unwrap() + .unwrap(); + + assert_eq!(updated.access_token.as_deref(), Some("new")); + assert_eq!(updated.refresh_token.as_deref(), Some("refresh")); + assert_eq!(updated.expires_at, Some(123)); + assert_eq!( + get_account(&dir, "claude:work") + .unwrap() + .unwrap() + .credential + .access_token + .as_deref(), + Some("new") + ); + let on_disk = fs::read_to_string(store_path(&dir)).expect("encrypted store"); + assert!(!on_disk.contains("\"old\"")); + assert!(!on_disk.contains("\"new\"")); + let _ = fs::remove_dir_all(dir); + provider_accounts_crypto::set_test_master_key(None); + } + + #[test] + #[serial] + fn migrates_legacy_plaintext_store_to_encrypted_file() { + provider_accounts_crypto::set_test_master_key(Some([9_u8; 32])); + let dir = temp_dir("migrate"); + let path = store_path(&dir); + let legacy = ProviderAccountsStore { + accounts: BTreeMap::from([( + "cursor:work".to_string(), + ProviderAccount { + instance_id: "cursor:work".into(), + base_provider_id: "cursor".into(), + label: "Work".into(), + credential: ProviderCredential { + access_token: Some("secret-token".into()), + refresh_token: None, + session_key: None, + expires_at: None, + }, + }, + )]), + }; + fs::write( + &path, + serde_json::to_string_pretty(&legacy).expect("legacy json"), + ) + .expect("write legacy"); + + let loaded = load_store_path(&path).expect("load migrates"); + assert_eq!( + loaded + .accounts + .get("cursor:work") + .and_then(|account| account.credential.access_token.as_deref()), + Some("secret-token") + ); + + let on_disk = fs::read_to_string(&path).expect("encrypted file"); + assert!(!on_disk.contains("secret-token")); + assert!(on_disk.contains(STORE_FORMAT_V1)); + + provider_accounts_crypto::set_test_master_key(None); + let _ = fs::remove_dir_all(dir); + } + + #[test] + #[cfg(unix)] + #[serial] + fn save_store_path_sets_private_file_permissions() { + provider_accounts_crypto::set_test_master_key(Some([3_u8; 32])); + use std::os::unix::fs::PermissionsExt; + + let dir = temp_dir("perms"); + let path = store_path(&dir); + save_store_path( + &path, + &ProviderAccountsStore { + accounts: BTreeMap::new(), + }, + ) + .unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + provider_accounts_crypto::set_test_master_key(None); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/crates/openusage-core/src/provider_accounts_crypto.rs b/crates/openusage-core/src/provider_accounts_crypto.rs new file mode 100644 index 000000000..4cd29dce2 --- /dev/null +++ b/crates/openusage-core/src/provider_accounts_crypto.rs @@ -0,0 +1,432 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Encrypt `provider_accounts.json` at rest. Master key lives in the OS keychain. + +use aes_gcm::{ + AesGcm, Nonce, + aead::{Aead, KeyInit, OsRng, generic_array::typenum::U16, rand_core::RngCore}, + aes::Aes256, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +const KEYCHAIN_SERVICE: &str = "OpenUsage"; +const KEYCHAIN_ACCOUNT: &str = "provider-accounts-master-key"; +pub const STORE_FORMAT_V1: &str = "openusage-provider-accounts-v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedProviderAccountsFile { + pub format: String, + pub envelope: String, +} + +#[cfg(test)] +static TEST_MASTER_KEY: std::sync::Mutex> = std::sync::Mutex::new(None); + +#[cfg(test)] +pub fn set_test_master_key(key: Option<[u8; 32]>) { + *TEST_MASTER_KEY.lock().expect("test master key lock") = key; +} + +pub fn encrypt_store_plaintext( + plaintext: &str, + app_data_dir: &Path, +) -> io::Result { + let key = master_key_bytes(app_data_dir)?; + let envelope = encrypt_aes_256_gcm_envelope(plaintext, &key)?; + Ok(EncryptedProviderAccountsFile { + format: STORE_FORMAT_V1.to_string(), + envelope, + }) +} + +pub fn decrypt_store_envelope(envelope: &str, app_data_dir: &Path) -> io::Result { + let key = master_key_bytes(app_data_dir)?; + decrypt_aes_256_gcm_envelope(envelope, &key) +} + +pub fn is_legacy_plaintext_store(text: &str) -> bool { + let trimmed = text.trim(); + if !trimmed.starts_with('{') { + return false; + } + serde_json::from_str::(trimmed) + .map(|wrapper| wrapper.format != STORE_FORMAT_V1) + .unwrap_or(true) +} + +fn master_key_bytes(app_data_dir: &Path) -> io::Result<[u8; 32]> { + #[cfg(test)] + { + if let Some(key) = *TEST_MASTER_KEY.lock().expect("test master key lock") { + return Ok(key); + } + } + + let key_b64 = read_or_create_master_key(app_data_dir).map_err(io::Error::other)?; + decode_master_key_b64(&key_b64) +} + +fn decode_master_key_b64(key_b64: &str) -> io::Result<[u8; 32]> { + let key = BASE64_STANDARD + .decode(key_b64.trim()) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if key.len() != 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid provider accounts master key length: {}", key.len()), + )); + } + let mut out = [0_u8; 32]; + out.copy_from_slice(&key); + Ok(out) +} + +fn master_key_file_path(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(".provider_accounts_master_key") +} + +fn read_or_create_master_key(app_data_dir: &Path) -> Result { + match read_or_create_keychain_master_key() { + Ok(key) => Ok(key), + Err(keychain_err) => { + log::warn!( + "provider accounts keychain unavailable ({keychain_err}); using app-local master key file" + ); + read_or_create_file_master_key(app_data_dir) + } + } +} + +fn read_or_create_file_master_key(app_data_dir: &Path) -> Result { + let path = master_key_file_path(app_data_dir); + if path.is_file() { + let text = fs::read_to_string(&path).map_err(|err| err.to_string())?; + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_string()); + } + } + + let encoded = create_random_key_b64(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + fs::write(&path, format!("{encoded}\n")).map_err(|err| err.to_string())?; + restrict_private_file_permissions(&path)?; + Ok(encoded) +} + +fn create_random_key_b64() -> String { + let mut raw = [0_u8; 32]; + OsRng.fill_bytes(&mut raw); + BASE64_STANDARD.encode(raw) +} + +#[cfg(unix)] +fn restrict_private_file_permissions(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|err| err.to_string()) +} + +#[cfg(not(unix))] +fn restrict_private_file_permissions(_path: &Path) -> Result<(), String> { + Ok(()) +} + +fn read_or_create_keychain_master_key() -> Result { + match read_platform_keyring_password(KEYCHAIN_SERVICE, Some(KEYCHAIN_ACCOUNT)) { + Ok(existing) if !existing.trim().is_empty() => Ok(existing), + Ok(_) => create_keychain_master_key(), + Err(_) => create_keychain_master_key(), + } +} + +fn create_keychain_master_key() -> Result { + let encoded = create_random_key_b64(); + write_platform_keyring_password(KEYCHAIN_SERVICE, Some(KEYCHAIN_ACCOUNT), &encoded)?; + Ok(encoded) +} + +fn encrypt_aes_256_gcm_envelope(plaintext: &str, key: &[u8; 32]) -> io::Result { + type Aes256Gcm16 = AesGcm; + let cipher = Aes256Gcm16::new_from_slice(key) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?; + let mut iv = [0_u8; 16]; + OsRng.fill_bytes(&mut iv); + let nonce = Nonce::::from_slice(&iv); + let ciphertext_and_tag = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::Other, "encrypt finalize failed"))?; + if ciphertext_and_tag.len() < 16 { + return Err(io::Error::new( + io::ErrorKind::Other, + "encrypted payload missing auth tag", + )); + } + let split_at = ciphertext_and_tag.len() - 16; + let (ciphertext, tag) = ciphertext_and_tag.split_at(split_at); + Ok(format!( + "{}:{}:{}", + BASE64_STANDARD.encode(iv), + BASE64_STANDARD.encode(tag), + BASE64_STANDARD.encode(ciphertext) + )) +} + +fn decrypt_aes_256_gcm_envelope(envelope: &str, key: &[u8; 32]) -> io::Result { + let parts: Vec<&str> = envelope.split(':').collect(); + if parts.len() != 3 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid encrypted provider accounts envelope", + )); + } + + let iv = BASE64_STANDARD + .decode(parts[0]) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if iv.len() != 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid AES-GCM iv length: {}", iv.len()), + )); + } + + let tag = BASE64_STANDARD + .decode(parts[1]) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + if tag.len() != 16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid AES-GCM auth tag length: {}", tag.len()), + )); + } + + let ciphertext = BASE64_STANDARD + .decode(parts[2]) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + + type Aes256Gcm16 = AesGcm; + let cipher = Aes256Gcm16::new_from_slice(key) + .map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?; + let nonce = Nonce::::from_slice(&iv); + let mut ciphertext_and_tag = ciphertext; + ciphertext_and_tag.extend_from_slice(&tag); + let plaintext = cipher + .decrypt(nonce, ciphertext_and_tag.as_ref()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "decrypt finalize failed"))?; + String::from_utf8(plaintext).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err)) +} + +#[cfg(target_os = "linux")] +fn dbus_session_bus_address() -> Option { + if let Ok(addr) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { + let trimmed = addr.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + let uid = users::get_current_uid(); + let bus_path = format!("/run/user/{uid}/bus"); + if std::path::Path::new(&bus_path).exists() { + return Some(format!("unix:path={bus_path}")); + } + None +} + +#[cfg(target_os = "linux")] +fn read_linux_secret_tool_password(service: &str, account: Option<&str>) -> Result { + let secret_tool = ["secret-tool", "/usr/bin/secret-tool"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()); + let secret_tool = secret_tool.ok_or_else(|| { + "secret-tool not installed (install libsecret-tools for Linux keyring access)".to_string() + })?; + + let mut cmd = std::process::Command::new(secret_tool); + cmd.arg("lookup").arg("service").arg(service); + if let Some(user) = account.map(str::trim).filter(|a| !a.is_empty()) { + cmd.arg("username").arg(user); + } + if let Some(addr) = dbus_session_bus_address() { + cmd.env("DBUS_SESSION_BUS_ADDRESS", addr); + } + + let output = cmd + .output() + .map_err(|e| format!("secret-tool failed: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().next().unwrap_or("").trim(); + return Err(if first_line.is_empty() { + "secret-tool lookup returned no entry".to_string() + } else { + first_line.to_string() + }); + } + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if password.is_empty() { + return Err("secret-tool lookup returned empty secret".to_string()); + } + Ok(password) +} + +#[cfg(target_os = "windows")] +fn windows_go_keyring_target(service: &str, user: &str) -> String { + format!("{service}:{user}") +} + +#[cfg(target_os = "windows")] +fn read_windows_keyring_password(service: &str, account: Option<&str>) -> Result { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + let mut errors: Vec = Vec::new(); + + if !user.is_empty() { + let go_target = windows_go_keyring_target(service, user); + match keyring::Entry::new_with_target(&go_target, service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => errors.push(format!("go-keyring target {go_target}: {e}")), + }, + Err(e) => errors.push(format!("go-keyring target {go_target}: {e}")), + } + } + + match keyring::Entry::new(service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => errors.push(format!("keyring crate default: {e}")), + }, + Err(e) => errors.push(format!("keyring crate default: {e}")), + } + + Err(errors.join("; ")) +} + +#[cfg(target_os = "windows")] +fn write_windows_keyring_password( + service: &str, + account: Option<&str>, + value: &str, +) -> Result<(), String> { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + if !user.is_empty() { + let go_target = windows_go_keyring_target(service, user); + if let Ok(entry) = keyring::Entry::new_with_target(&go_target, service, user) { + if entry.set_password(value).is_ok() { + return Ok(()); + } + } + } + keyring::Entry::new(service, user) + .map_err(|e| e.to_string())? + .set_password(value) + .map_err(|e| e.to_string()) +} + +fn write_platform_keyring_password( + service: &str, + account: Option<&str>, + value: &str, +) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + return write_windows_keyring_password(service, account, value); + } + #[cfg(not(target_os = "windows"))] + { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + keyring::Entry::new(service, user) + .map_err(|e| e.to_string())? + .set_password(value) + .map_err(|e| e.to_string()) + } +} + +fn read_platform_keyring_password(service: &str, account: Option<&str>) -> Result { + #[cfg(target_os = "windows")] + { + return read_windows_keyring_password(service, account); + } + + #[cfg(not(target_os = "windows"))] + { + let user = account + .map(str::trim) + .filter(|a| !a.is_empty()) + .unwrap_or(""); + let keyring_err = match keyring::Entry::new(service, user) { + Ok(entry) => match entry.get_password() { + Ok(password) => return Ok(password), + Err(e) => e.to_string(), + }, + Err(e) => e.to_string(), + }; + + #[cfg(target_os = "linux")] + { + return read_linux_secret_tool_password(service, account) + .map_err(|secret_tool_err| format!("{keyring_err}; {secret_tool_err}")); + } + + #[cfg(not(target_os = "linux"))] + { + let _ = keyring_err; + Err("keyring read failed".to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + fn test_key() -> [u8; 32] { + let mut key = [0_u8; 32]; + for (index, byte) in key.iter_mut().enumerate() { + *byte = index as u8; + } + key + } + + #[test] + #[serial] + fn encrypt_decrypt_roundtrip() { + set_test_master_key(Some(test_key())); + let dir = std::env::temp_dir(); + let plaintext = r#"{"accounts":{"claude:work":{"instanceId":"claude:work"}}}"#; + let encrypted = encrypt_store_plaintext(plaintext, &dir).expect("encrypt"); + assert_eq!(encrypted.format, STORE_FORMAT_V1); + assert!(!encrypted.envelope.contains("accessToken")); + let decrypted = decrypt_store_envelope(&encrypted.envelope, &dir).expect("decrypt"); + assert_eq!(decrypted, plaintext); + set_test_master_key(None); + } + + #[test] + fn legacy_plaintext_detection() { + assert!(is_legacy_plaintext_store( + r#"{"accounts":{"cursor:work":{"credential":{"accessToken":"secret"}}}}"# + )); + let encrypted = EncryptedProviderAccountsFile { + format: STORE_FORMAT_V1.to_string(), + envelope: "a:b:c".into(), + }; + let text = serde_json::to_string(&encrypted).expect("serialize"); + assert!(!is_legacy_plaintext_store(&text)); + } +} diff --git a/crates/openusage-core/src/proxy_config.rs b/crates/openusage-core/src/proxy_config.rs new file mode 100644 index 000000000..20e09d7d2 --- /dev/null +++ b/crates/openusage-core/src/proxy_config.rs @@ -0,0 +1,130 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Optional HTTP/SOCKS proxy from `~/.openusage/config.json`. + +use reqwest::Proxy; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Deserialize)] +pub struct ProxyConfig { + pub enabled: bool, + pub url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub proxy: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedProxy { + pub proxy: Proxy, +} + +static RESOLVED_PROXY: OnceLock> = OnceLock::new(); + +pub fn get_resolved_proxy() -> Option<&'static ResolvedProxy> { + RESOLVED_PROXY.get_or_init(load_and_resolve_proxy).as_ref() +} + +/// Config files checked in order for `proxy` settings (first file wins if it enables a proxy). +fn user_proxy_config_paths() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + vec![home.join(".openusage").join("config.json")] +} + +fn resolve_proxy_from_config_contents(contents: &str, path: &Path) -> Option { + let config: AppConfig = match serde_json::from_str(contents) { + Ok(cfg) => cfg, + Err(e) => { + log::warn!( + "[config] failed to parse {}: {}, skipping", + path.display(), + e + ); + return None; + } + }; + + let Some(proxy_cfg) = config.proxy.as_ref().filter(|p| p.enabled) else { + log::debug!("[config] proxy disabled or missing in {}", path.display()); + return None; + }; + + match Proxy::all(&proxy_cfg.url) { + Ok(proxy) => { + let redacted = redact_proxy_url(&proxy_cfg.url); + log::debug!( + "[config] proxy enabled from {}: {}", + path.display(), + redacted + ); + + let no_proxy = reqwest::NoProxy::from_string("localhost,127.0.0.1,::1"); + let proxy = proxy.no_proxy(no_proxy); + + Some(ResolvedProxy { proxy }) + } + Err(e) => { + log::warn!("[config] proxy invalid in {}: {}", path.display(), e); + None + } + } +} + +fn load_and_resolve_proxy() -> Option { + let paths = user_proxy_config_paths(); + if paths.is_empty() { + log::debug!("[config] no home directory, proxy disabled"); + return None; + } + + for path in paths { + let contents = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => continue, + }; + if let Some(resolved) = resolve_proxy_from_config_contents(&contents, &path) { + return Some(resolved); + } + } + + log::debug!("[config] no enabled proxy in user config files"); + None +} + +pub fn redact_proxy_url(url: &str) -> String { + if let Some(at_pos) = url.find('@') { + if let Some(scheme_end) = url.find("://") { + let userinfo_start = scheme_end + 3; + format!("{}***@{}", &url[..userinfo_start], &url[at_pos + 1..]) + } else { + format!("***@{}", &url[at_pos + 1..]) + } + } else { + url.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redact_proxy_url_with_credentials() { + let url = "http://user:pass@127.0.0.1:10808"; + let redacted = redact_proxy_url(url); + assert_eq!(redacted, "http://***@127.0.0.1:10808"); + assert!(!redacted.contains("user")); + assert!(!redacted.contains("pass")); + } + + #[test] + fn redact_proxy_url_without_credentials() { + let url = "http://127.0.0.1:10808"; + assert_eq!(redact_proxy_url(url), url); + } +} diff --git a/crates/openusage-core/src/usage_daily.rs b/crates/openusage-core/src/usage_daily.rs new file mode 100644 index 000000000..1e5e16dde --- /dev/null +++ b/crates/openusage-core/src/usage_daily.rs @@ -0,0 +1,260 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Daily token rows ingested from ccusage (Claude/Codex local logs). Same SQLite DB as usage history. + +use std::path::Path; + +use rusqlite::{Connection, params}; +use serde::Deserialize; + +use crate::usage_history::{init_schema, persist_usage_history_enabled, usage_history_db_path}; + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageDailyRow { + pub instance_id: String, + pub day_key: String, + pub display_name: String, + pub total_tokens: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub cost_usd: Option, + pub source: String, + pub ingested_at_ms: i64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct IngestPayload { + display_name: Option, + source: Option, + daily: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DailyEntry { + date: String, + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, + cost_usd: Option, + total_cost: Option, +} + +fn normalize_day_key(raw: &str) -> Option { + let s = raw.trim(); + if s.len() >= 10 && s.as_bytes().get(4) == Some(&b'-') { + return Some(s[..10].to_string()); + } + let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() >= 8 { + return Some(format!( + "{}-{}-{}", + &digits[0..4], + &digits[4..6], + &digits[6..8] + )); + } + None +} + +fn entry_cost(entry: &DailyEntry) -> Option { + entry + .cost_usd + .or(entry.total_cost) + .filter(|v| v.is_finite()) +} + +/// Plugins call via `host.usageDaily.ingest` after a successful ccusage query. +pub fn ingest_json( + app_data_dir: &Path, + instance_id: &str, + payload_json: &str, +) -> rusqlite::Result<()> { + if !persist_usage_history_enabled(app_data_dir) { + return Ok(()); + } + + let payload: IngestPayload = match serde_json::from_str(payload_json) { + Ok(v) => v, + Err(e) => { + log::warn!( + "[plugin:{}] usageDaily.ingest invalid JSON: {}", + instance_id, + e + ); + return Ok(()); + } + }; + + if payload.daily.is_empty() { + return Ok(()); + } + + let display_name = payload + .display_name + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| instance_id.to_string()); + let source = payload + .source + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "ccusage".to_string()); + + let path = usage_history_db_path(app_data_dir); + let conn = Connection::open(&path)?; + init_schema(&conn)?; + + let now_ms: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + + for entry in &payload.daily { + let Some(day_key) = normalize_day_key(&entry.date) else { + continue; + }; + conn.execute( + r"INSERT INTO usage_daily ( + instance_id, day_key, display_name, total_tokens, input_tokens, + output_tokens, cost_usd, source, ingested_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(instance_id, day_key) DO UPDATE SET + display_name = excluded.display_name, + total_tokens = excluded.total_tokens, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cost_usd = excluded.cost_usd, + source = excluded.source, + ingested_at_ms = excluded.ingested_at_ms", + params![ + instance_id, + day_key, + display_name, + entry.total_tokens, + entry.input_tokens, + entry.output_tokens, + entry_cost(entry), + source, + now_ms, + ], + )?; + } + + Ok(()) +} + +pub fn list_recent( + app_data_dir: &Path, + limit: u32, + instance_id: Option<&str>, +) -> rusqlite::Result> { + let path = usage_history_db_path(app_data_dir); + if !path.exists() { + return Ok(vec![]); + } + let conn = Connection::open(&path)?; + init_schema(&conn)?; + let lim = i64::from(limit).max(1).min(500); + + let mut out = Vec::new(); + if let Some(id) = instance_id { + let mut stmt = conn.prepare_cached( + "SELECT instance_id, day_key, display_name, total_tokens, input_tokens, + output_tokens, cost_usd, source, ingested_at_ms + FROM usage_daily WHERE instance_id = ?1 + ORDER BY day_key DESC LIMIT ?2", + )?; + let rows = stmt.query_map(params![id, lim], map_daily_row)?; + for row in rows { + out.push(row?); + } + } else { + let mut stmt = conn.prepare_cached( + "SELECT instance_id, day_key, display_name, total_tokens, input_tokens, + output_tokens, cost_usd, source, ingested_at_ms + FROM usage_daily ORDER BY day_key DESC, instance_id ASC LIMIT ?1", + )?; + let rows = stmt.query_map([lim], map_daily_row)?; + for row in rows { + out.push(row?); + } + } + Ok(out) +} + +fn map_daily_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UsageDailyRow { + instance_id: r.get(0)?, + day_key: r.get(1)?, + display_name: r.get(2)?, + total_tokens: r.get(3)?, + input_tokens: r.get(4)?, + output_tokens: r.get(5)?, + cost_usd: r.get(6)?, + source: r.get(7)?, + ingested_at_ms: r.get(8)?, + }) +} + +pub fn clear_all(app_data_dir: &Path) -> rusqlite::Result<()> { + let path = usage_history_db_path(app_data_dir); + if !path.exists() { + return Ok(()); + } + let conn = Connection::open(&path)?; + conn.execute("DELETE FROM usage_daily", [])?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_settings(dir: &Path, persist: bool) { + let json = format!(r#"{{"persistUsageHistory":{persist}}}"#); + std::fs::write(dir.join("settings.json"), json).unwrap(); + } + + #[test] + fn ingest_skipped_when_persist_disabled() { + let dir = tempfile::tempdir().expect("tempdir"); + write_settings(dir.path(), false); + let payload = + r#"{"displayName":"Claude","daily":[{"date":"2026-05-01","totalTokens":100}]}"#; + ingest_json(dir.path(), "claude", payload).unwrap(); + let rows = list_recent(dir.path(), 10, None).unwrap(); + assert!(rows.is_empty()); + } + + #[test] + fn ingest_upserts_daily_rows() { + let dir = tempfile::tempdir().expect("tempdir"); + write_settings(dir.path(), true); + let payload = r#"{ + "displayName":"Claude", + "daily":[ + {"date":"2026-05-01","totalTokens":100,"inputTokens":40,"outputTokens":60,"totalCost":1.5}, + {"date":"20260502","totalTokens":200,"costUSD":2.0} + ] + }"#; + ingest_json(dir.path(), "claude", payload).unwrap(); + let rows = list_recent(dir.path(), 10, None).unwrap(); + assert_eq!(rows.len(), 2); + let may1 = rows + .iter() + .find(|r| r.day_key == "2026-05-01") + .expect("may1"); + assert_eq!(may1.total_tokens, Some(100)); + assert!((may1.cost_usd.unwrap() - 1.5).abs() < f64::EPSILON); + + let payload2 = r#"{"daily":[{"date":"2026-05-01","totalTokens":150}]}"#; + ingest_json(dir.path(), "claude", payload2).unwrap(); + let rows2 = list_recent(dir.path(), 10, Some("claude")).unwrap(); + assert_eq!(rows2.len(), 2); + let updated = rows2 + .iter() + .find(|r| r.day_key == "2026-05-01") + .expect("updated"); + assert_eq!(updated.total_tokens, Some(150)); + } +} diff --git a/crates/openusage-core/src/usage_history.rs b/crates/openusage-core/src/usage_history.rs new file mode 100644 index 000000000..843417c30 --- /dev/null +++ b/crates/openusage-core/src/usage_history.rs @@ -0,0 +1,421 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Local SQLite snapshots after successful probes (desktop app). CLI uses separate JSONL. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use rusqlite::{Connection, params}; +use serde::Serialize; + +use crate::plugin_engine::runtime::PluginOutput; +use crate::usage_metrics::{NormalizedMetrics, NormalizedMetricsMapper}; + +const DEBOUNCE_PER_INSTANCE: Duration = Duration::from_secs(32); +const MAX_ROWS: i64 = 12_000; + +static DEBOUNCE_LAST: Mutex>> = Mutex::new(None); + +fn debounce_map() -> std::sync::MutexGuard<'static, Option>> { + DEBOUNCE_LAST + .lock() + .expect("usage history debounce mutex poisoned") +} + +/// Returns `true` if we should record (debounce window elapsed for this instance). +fn debounce_allow(instance_id: &str) -> bool { + let mut guard = debounce_map(); + let map = guard.get_or_insert_with(HashMap::new); + let now = Instant::now(); + if let Some(prev) = map.get(instance_id) { + if now.duration_since(*prev) < DEBOUNCE_PER_INSTANCE { + return false; + } + } + map.insert(instance_id.to_string(), now); + true +} + +pub fn usage_history_db_path(app_data_dir: &Path) -> PathBuf { + app_data_dir.join("usage_history.sqlite3") +} + +fn read_settings_json(app_data_dir: &Path) -> Option { + let path = app_data_dir.join("settings.json"); + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() +} + +/// Reads Tauri `settings.json` in app data (same file as the desktop settings store). +pub fn persist_usage_history_enabled(app_data_dir: &Path) -> bool { + read_settings_json(app_data_dir) + .and_then(|v| v.get("persistUsageHistory").and_then(|x| x.as_bool())) + .unwrap_or(false) +} + +/// Days to keep history rows (`0` = no age-based prune; row cap still applies). +pub fn usage_history_retention_days(app_data_dir: &Path) -> u32 { + read_settings_json(app_data_dir) + .and_then(|v| v.get("usageHistoryRetentionDays").and_then(|x| x.as_u64())) + .map(|n| n.min(3650) as u32) + .unwrap_or(90) +} + +pub fn prune_by_retention(app_data_dir: &Path) -> rusqlite::Result<()> { + let days = usage_history_retention_days(app_data_dir); + if days == 0 { + return Ok(()); + } + let path = usage_history_db_path(app_data_dir); + if !path.exists() { + return Ok(()); + } + let cutoff_ms = chrono::Utc::now().timestamp_millis() - i64::from(days) * 86_400_000; + let conn = Connection::open(&path)?; + init_schema(&conn)?; + conn.execute( + "DELETE FROM usage_history WHERE captured_at_ms < ?1", + [cutoff_ms], + )?; + let cutoff_day = chrono::DateTime::from_timestamp_millis(cutoff_ms) + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default(); + if !cutoff_day.is_empty() { + let _ = conn.execute( + "DELETE FROM usage_daily WHERE day_key < ?1", + [cutoff_day.as_str()], + ); + } + Ok(()) +} + +pub fn init_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + r" + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + instance_id TEXT NOT NULL, + captured_at_ms INTEGER NOT NULL, + display_name TEXT NOT NULL, + plan TEXT, + primary_percent REAL NOT NULL, + input_tokens INTEGER, + output_tokens INTEGER, + cost REAL, + reset_time TEXT, + quota_summary TEXT + ); + CREATE INDEX IF NOT EXISTS idx_usage_hist_instance_time + ON usage_history(instance_id, captured_at_ms DESC); + CREATE TABLE IF NOT EXISTS usage_daily ( + instance_id TEXT NOT NULL, + day_key TEXT NOT NULL, + display_name TEXT NOT NULL, + total_tokens INTEGER, + input_tokens INTEGER, + output_tokens INTEGER, + cost_usd REAL, + source TEXT NOT NULL DEFAULT 'ccusage', + ingested_at_ms INTEGER NOT NULL, + PRIMARY KEY (instance_id, day_key) + ); + CREATE INDEX IF NOT EXISTS idx_usage_daily_instance_day + ON usage_daily(instance_id, day_key DESC); + ", + )?; + let _ = conn.execute( + "ALTER TABLE usage_daily ADD COLUMN source TEXT NOT NULL DEFAULT 'ccusage'", + [], + ); + Ok(()) +} + +/// Append one normalized row after a successful probe. Skips if debounce window not elapsed. +pub fn append_probe_snapshot(app_data_dir: &Path, output: &PluginOutput) -> rusqlite::Result<()> { + if !persist_usage_history_enabled(app_data_dir) { + return Ok(()); + } + if !debounce_allow(output.provider_id.as_str()) { + return Ok(()); + } + + let path = usage_history_db_path(app_data_dir); + let conn = Connection::open(&path)?; + init_schema(&conn)?; + + let m = NormalizedMetricsMapper::from_output(output); + let now_ms: i64 = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis().min(i64::MAX as u128) as i64) + .unwrap_or(0); + + insert_row(&conn, output, &m, now_ms)?; + trim_old_rows(&conn)?; + drop(conn); + let _ = prune_by_retention(app_data_dir); + Ok(()) +} + +fn insert_row( + conn: &Connection, + output: &PluginOutput, + m: &NormalizedMetrics, + captured_at_ms: i64, +) -> rusqlite::Result<()> { + conn.execute( + r"INSERT INTO usage_history ( + instance_id, captured_at_ms, display_name, plan, primary_percent, + input_tokens, output_tokens, cost, reset_time, quota_summary + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + output.provider_id.as_str(), + captured_at_ms, + output.display_name.as_str(), + output.plan.as_deref(), + m.primary_percent, + m.input_tokens.map(|n| n as i64), + m.output_tokens.map(|n| n as i64), + m.cost, + m.reset_time.as_deref(), + m.list_quota_summary.as_deref(), + ], + )?; + Ok(()) +} + +fn trim_old_rows(conn: &Connection) -> rusqlite::Result<()> { + let n: i64 = conn.query_row("SELECT COUNT(*) FROM usage_history", [], |r| r.get(0))?; + if n <= MAX_ROWS { + return Ok(()); + } + let delete_n = n - MAX_ROWS; + conn.execute( + "DELETE FROM usage_history WHERE id IN ( + SELECT id FROM usage_history ORDER BY captured_at_ms ASC LIMIT ?1 + )", + [delete_n], + )?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageHistoryRow { + pub id: i64, + pub instance_id: String, + pub captured_at_ms: i64, + pub display_name: String, + pub plan: Option, + pub primary_percent: f64, + pub input_tokens: Option, + pub output_tokens: Option, + pub cost: Option, + pub reset_time: Option, + pub quota_summary: Option, +} + +pub fn list_recent(app_data_dir: &Path, limit: u32) -> rusqlite::Result> { + let path = usage_history_db_path(app_data_dir); + if !path.exists() { + return Ok(vec![]); + } + let conn = Connection::open(&path)?; + init_schema(&conn)?; + let lim = i64::from(limit).max(1).min(500); + let mut stmt = conn.prepare_cached( + "SELECT id, instance_id, captured_at_ms, display_name, plan, primary_percent, + input_tokens, output_tokens, cost, reset_time, quota_summary + FROM usage_history ORDER BY captured_at_ms DESC LIMIT ?1", + )?; + let rows = stmt.query_map([lim], |r| { + Ok(UsageHistoryRow { + id: r.get(0)?, + instance_id: r.get(1)?, + captured_at_ms: r.get(2)?, + display_name: r.get(3)?, + plan: r.get(4)?, + primary_percent: r.get(5)?, + input_tokens: r.get(6)?, + output_tokens: r.get(7)?, + cost: r.get(8)?, + reset_time: r.get(9)?, + quota_summary: r.get(10)?, + }) + })?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryInsightTightest { + pub instance_id: String, + pub display_name: String, + /// Usage percent on primary line (0–100). + pub primary_percent: f64, + pub remaining_percent: f64, + pub captured_at_ms: i64, + pub reset_time: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryInsightsSummary { + pub generated_at_ms: i64, + pub retention_days: u32, + pub tightest: Vec, +} + +pub fn insights_summary( + app_data_dir: &Path, + limit: u32, +) -> rusqlite::Result { + let generated_at_ms = chrono::Utc::now().timestamp_millis(); + let retention_days = usage_history_retention_days(app_data_dir); + if !persist_usage_history_enabled(app_data_dir) { + return Ok(HistoryInsightsSummary { + generated_at_ms, + retention_days, + tightest: vec![], + }); + } + let rows = list_recent(app_data_dir, 500)?; + let mut latest_by_instance: std::collections::HashMap = + std::collections::HashMap::new(); + for row in rows { + latest_by_instance + .entry(row.instance_id.clone()) + .and_modify(|prev| { + if row.captured_at_ms > prev.captured_at_ms { + *prev = row.clone(); + } + }) + .or_insert(row); + } + let mut tightest: Vec = latest_by_instance + .into_values() + .map(|r| HistoryInsightTightest { + remaining_percent: (100.0 - r.primary_percent).clamp(0.0, 100.0), + instance_id: r.instance_id, + display_name: r.display_name, + primary_percent: r.primary_percent, + captured_at_ms: r.captured_at_ms, + reset_time: r.reset_time, + }) + .collect(); + tightest.sort_by(|a, b| { + a.primary_percent + .partial_cmp(&b.primary_percent) + .unwrap_or(std::cmp::Ordering::Equal) + .reverse() + }); + tightest.truncate(limit.max(1).min(20) as usize); + Ok(HistoryInsightsSummary { + generated_at_ms, + retention_days, + tightest, + }) +} + +pub fn clear_all(app_data_dir: &Path) -> rusqlite::Result<()> { + let path = usage_history_db_path(app_data_dir); + if !path.exists() { + return Ok(()); + } + let conn = Connection::open(&path)?; + conn.execute("DELETE FROM usage_history", [])?; + let _ = conn.execute("DELETE FROM usage_daily", []); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_engine::runtime::{MetricLine, ProgressFormat}; + + fn write_settings(dir: &Path, persist: bool) { + let json = format!(r#"{{"persistUsageHistory":{persist}}}"#); + std::fs::write(dir.join("settings.json"), json).unwrap(); + } + + fn sample_output(id: &str) -> PluginOutput { + PluginOutput { + provider_id: id.into(), + display_name: "Test".into(), + plan: Some("pro".into()), + warning: None, + icon_url: String::new(), + lines: vec![MetricLine::Progress { + label: "Usage".into(), + used: 40.0, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: None, + period_duration_ms: None, + color: None, + }], + } + } + + #[test] + fn append_list_clear_roundtrip() { + let dir = tempfile::tempdir().expect("tempdir"); + write_settings(dir.path(), true); + let out = sample_output("cursor"); + append_probe_snapshot(dir.path(), &out).unwrap(); + let rows = list_recent(dir.path(), 10).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].instance_id, "cursor"); + + clear_all(dir.path()).unwrap(); + let rows2 = list_recent(dir.path(), 10).unwrap(); + assert!(rows2.is_empty()); + } + + #[test] + fn debounce_skips_second_write_immediately() { + let dir = tempfile::tempdir().expect("tempdir"); + write_settings(dir.path(), true); + let out = sample_output("debounce_test_instance"); + append_probe_snapshot(dir.path(), &out).unwrap(); + append_probe_snapshot(dir.path(), &out).unwrap(); + let rows = list_recent(dir.path(), 10).unwrap(); + assert_eq!( + rows.len(), + 1, + "second append within debounce window should skip" + ); + } + + fn write_settings_with_retention(dir: &Path, persist: bool, retention_days: u32) { + let json = format!( + r#"{{"persistUsageHistory":{persist},"usageHistoryRetentionDays":{retention_days}}}"# + ); + std::fs::write(dir.join("settings.json"), json).unwrap(); + } + + #[test] + fn prune_by_retention_drops_old_rows() { + let dir = tempfile::tempdir().expect("tempdir"); + write_settings_with_retention(dir.path(), true, 7); + let path = usage_history_db_path(dir.path()); + let conn = Connection::open(&path).unwrap(); + init_schema(&conn).unwrap(); + let old_ms = chrono::Utc::now().timestamp_millis() - 10 * 86_400_000; + conn.execute( + "INSERT INTO usage_history (instance_id, captured_at_ms, display_name, primary_percent) + VALUES ('cursor', ?1, 'Cursor', 50.0)", + [old_ms], + ) + .unwrap(); + prune_by_retention(dir.path()).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM usage_history", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + } +} diff --git a/crates/openusage-core/src/usage_metrics.rs b/crates/openusage-core/src/usage_metrics.rs new file mode 100644 index 000000000..0c301d232 --- /dev/null +++ b/crates/openusage-core/src/usage_metrics.rs @@ -0,0 +1,444 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Best-effort normalization of [`PluginOutput`](crate::plugin_engine::runtime::PluginOutput) lines. + +use crate::plugin_engine::runtime::{MetricLine, PluginOutput, ProgressFormat}; + +/// Standard numeric/categorical fields parsed from plugin text (no core changes required). +#[derive(Debug, Clone, Default)] +pub struct NormalizedMetrics { + pub input_tokens: Option, + pub output_tokens: Option, + pub cost: Option, + pub reset_time: Option, + /// Best-effort cache hit count when labels mention cache. + pub cache_hits: Option, + /// 0.0–100.0 — max %-used across `Progress` lines (percent format), when several + /// (e.g. Antigravity per-model rows); otherwise first progress / heuristics. + pub primary_percent: f64, + /// When the plugin reports **2+** percent progress rows (e.g. Antigravity: Gemini + Claude + GPT-OSS), + /// a compact string for `openusage-cli list`: short labels + commas (see `format_list_quota_summary`). + pub list_quota_summary: Option, +} + +/// Shorten long model names so the `list` table stays readable in narrow terminals. +fn truncate_label_for_list_quota(s: &str, max_chars: usize) -> String { + let n = s.chars().count(); + if n <= max_chars { + return s.to_string(); + } + let take = max_chars.saturating_sub(1); + s.chars().take(take).collect::() + "…" +} + +/// Compact multi-model quota for the text table: comma-separated, capped width/parts. +/// The `mock` plugin is a **demo/chaos** provider — one line instead of dozens of fake rows. +fn format_list_quota_summary(provider_id: &str, percent_lines: &[(String, f64)]) -> String { + const MAX_PARTS: usize = 6; + const MAX_LABEL: usize = 18; + const MAX_TOTAL_CHARS: usize = 96; + + if provider_id == "mock" { + let n = percent_lines.len(); + return format!("Demo chaos plugin ({n} %-rows — not real usage; `probe mock` for full)"); + } + + let take = percent_lines.len().min(MAX_PARTS); + let mut segments: Vec = Vec::with_capacity(take + 1); + for (l, p) in percent_lines.iter().take(take) { + let lbl = truncate_label_for_list_quota(l, MAX_LABEL); + segments.push(format!("{lbl} {:.0}%", p)); + } + let mut s = segments.join(", "); + if percent_lines.len() > take { + s.push_str(&format!(", +{} more", percent_lines.len() - take)); + } + if s.chars().count() > MAX_TOTAL_CHARS { + let keep = MAX_TOTAL_CHARS.saturating_sub(1); + s = s.chars().take(keep).collect::() + "…"; + } + s +} + +fn parse_u64_loose(s: &str) -> Option { + let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; + } + digits.parse().ok() +} + +fn parse_money(s: &str) -> Option { + let cleaned: String = s + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-') + .collect(); + cleaned.parse().ok() +} + +fn label_value_tokens(line: &MetricLine) -> Option<(&str, &str)> { + match line { + MetricLine::Text { label, value, .. } => Some((label.as_str(), value.as_str())), + MetricLine::Badge { label, text, .. } => Some((label.as_str(), text.as_str())), + MetricLine::Progress { .. } => None, + MetricLine::BarChart { .. } => None, + } +} + +/// Heuristic mapper: scans labels/values for common English patterns. +pub struct NormalizedMetricsMapper; + +impl NormalizedMetricsMapper { + pub fn from_output(out: &PluginOutput) -> NormalizedMetrics { + let mut m = NormalizedMetrics::default(); + let mut primary_ratio: Option = None; + let mut max_percent: f64 = 0.0; + let mut percent_lines: Vec<(String, f64)> = Vec::new(); + // Sum `used` from `progress` lines with `format: count` (tokens / requests / credits). + let mut count_input_sum: u64 = 0; + let mut count_input_lines: usize = 0; + let mut count_output_sum: u64 = 0; + let mut count_output_lines: usize = 0; + + for line in &out.lines { + if let MetricLine::Progress { + used, + limit, + format, + label, + resets_at, + .. + } = line + { + if *limit > 0.0 { + let r = (*used / *limit).min(1.0).max(0.0); + if primary_ratio.is_none() { + primary_ratio = Some(r); + } + } + match format { + ProgressFormat::Percent => { + if *limit > 0.0 { + let pct = (*used / *limit * 100.0).min(100.0).max(0.0); + if pct > max_percent { + max_percent = pct; + } + percent_lines.push((label.clone(), pct)); + } + } + ProgressFormat::Dollars => { + let lab = label.to_lowercase(); + // Cursor "Credits" uses `used` = dollars spent toward the pool; `limit` = pool size. + // When `used` is 0 but the pool exists, the first-dollars-line heuristic was storing $0 + // even though the UI shows dollars *left* (limit − used). Prefer that for credit rows. + let dollars_snapshot = if lab.contains("credit") + && limit.is_finite() + && used.is_finite() + && *limit > 0.0 + { + (*limit - *used).max(0.0) + } else { + *used + }; + if lab.contains("credit") { + m.cost = Some(dollars_snapshot); + } else if m.cost.is_none() { + m.cost = Some(dollars_snapshot); + } + } + ProgressFormat::Count { suffix } => { + let suf = suffix.to_lowercase(); + let lab = label.to_lowercase(); + let n = if used.is_finite() && *used >= 0.0 { + (*used).min(u64::MAX as f64) as u64 + } else { + 0 + }; + let is_req = suf.contains("request") || lab.contains("request"); + let is_tok = suf.contains("token") || lab.contains("token"); + let is_cred = suf.contains("credit") || lab.contains("credit"); + if is_tok { + if lab.contains("output") { + count_output_sum = count_output_sum.saturating_add(n); + count_output_lines += 1; + } else { + count_input_sum = count_input_sum.saturating_add(n); + count_input_lines += 1; + } + } else if is_req || is_cred { + count_input_sum = count_input_sum.saturating_add(n); + count_input_lines += 1; + } + } + } + if resets_at.is_some() && m.reset_time.is_none() { + m.reset_time = resets_at.clone(); + } + + let combined = format!("{} {}", label.to_lowercase(), ""); + if combined.contains("input") && combined.contains("token") { + m.input_tokens = parse_u64_loose(&used.to_string()); + } + if combined.contains("output") && combined.contains("token") { + m.output_tokens = parse_u64_loose(&used.to_string()); + } + } + + if let Some((label, value)) = label_value_tokens(line) { + let lk = label.to_lowercase(); + let vk = value.to_lowercase(); + let blob = format!("{} {}", lk, vk); + + if blob.contains("input") && (blob.contains("token") || blob.contains("tok")) { + m.input_tokens = m.input_tokens.or_else(|| parse_u64_loose(value)); + } + if blob.contains("output") && (blob.contains("token") || blob.contains("tok")) { + m.output_tokens = m.output_tokens.or_else(|| parse_u64_loose(value)); + } + if lk.contains("cost") || vk.contains('$') || blob.contains("usd") { + m.cost = m.cost.or_else(|| parse_money(value)); + } + if lk.contains("reset") || vk.contains("reset") || blob.contains("resets") { + if m.reset_time.is_none() { + m.reset_time = Some(value.to_string()); + } + } + if blob.contains("cache") && (blob.contains("hit") || lk.contains("cache")) { + m.cache_hits = m.cache_hits.or_else(|| parse_u64_loose(value)); + } + } + } + + if m.input_tokens.is_none() && count_input_lines > 0 { + m.input_tokens = Some(count_input_sum); + } + if m.output_tokens.is_none() && count_output_lines > 0 { + m.output_tokens = Some(count_output_sum); + } + + m.primary_percent = max_percent; + if m.primary_percent <= 0.0 { + if let Some(r) = primary_ratio { + m.primary_percent = r * 100.0; + } + } + + if percent_lines.len() >= 2 { + m.list_quota_summary = Some(format_list_quota_summary( + out.provider_id.as_str(), + &percent_lines, + )); + } + + m + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_engine::runtime::ProgressFormat; + + fn sample_output() -> PluginOutput { + PluginOutput { + provider_id: "mock".into(), + display_name: "Mock".into(), + plan: Some("pro".into()), + warning: None, + icon_url: String::new(), + lines: vec![ + MetricLine::Text { + label: "Input tokens".into(), + value: "12000".into(), + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }, + MetricLine::Text { + label: "Output tokens".into(), + value: "3400".into(), + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }, + MetricLine::Text { + label: "Cost".into(), + value: "$1.23".into(), + color: None, + subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, + }, + MetricLine::Progress { + label: "Usage".into(), + used: 45.0, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: Some("tomorrow".into()), + period_duration_ms: None, + color: None, + }, + ], + } + } + + #[test] + fn mapper_extracts_fields() { + let m = NormalizedMetricsMapper::from_output(&sample_output()); + assert_eq!(m.input_tokens, Some(12000)); + assert_eq!(m.output_tokens, Some(3400)); + assert!((m.cost.unwrap() - 1.23).abs() < 0.01); + assert_eq!(m.reset_time.as_deref(), Some("tomorrow")); + assert!((m.primary_percent - 45.0).abs() < 0.01); + } + + #[test] + fn mapper_primary_is_max_percent_across_progress_lines() { + let out = PluginOutput { + provider_id: "antigravity".into(), + display_name: "Antigravity".into(), + plan: None, + warning: None, + icon_url: String::new(), + lines: vec![ + MetricLine::Progress { + label: "Gemini Pro".into(), + used: 0.0, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: None, + period_duration_ms: None, + color: None, + }, + MetricLine::Progress { + label: "Claude Sonnet 4.6".into(), + used: 72.0, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: None, + period_duration_ms: None, + color: None, + }, + ], + }; + let m = NormalizedMetricsMapper::from_output(&out); + assert!((m.primary_percent - 72.0).abs() < 0.01); + let s = m.list_quota_summary.as_deref().unwrap_or(""); + assert!(s.contains("Gemini Pro")); + assert!(s.contains("72")); + assert!(s.contains("Claude")); + } + + #[test] + fn mapper_credits_dollars_uses_remaining_when_used_is_zero() { + let out = PluginOutput { + provider_id: "cursor".into(), + display_name: "Cursor".into(), + plan: Some("Pro".into()), + warning: None, + icon_url: String::new(), + lines: vec![MetricLine::Progress { + label: "Credits".into(), + used: 0.0, + limit: 108.35, + format: ProgressFormat::Dollars, + resets_at: None, + period_duration_ms: None, + color: None, + }], + }; + let m = NormalizedMetricsMapper::from_output(&out); + assert!((m.cost.unwrap() - 108.35).abs() < 0.01); + } + + #[test] + fn mapper_cost_from_dollars_progress() { + let out = PluginOutput { + provider_id: "cursor".into(), + display_name: "Cursor".into(), + plan: None, + warning: None, + icon_url: String::new(), + lines: vec![MetricLine::Progress { + label: "On-demand".into(), + used: 12.34, + limit: 50.0, + format: ProgressFormat::Dollars, + resets_at: None, + period_duration_ms: None, + color: None, + }], + }; + let m = NormalizedMetricsMapper::from_output(&out); + assert!((m.cost.unwrap() - 12.34).abs() < 0.01); + } + + #[test] + fn mapper_sums_count_token_progress_lines() { + let out = PluginOutput { + provider_id: "factory".into(), + display_name: "Factory".into(), + plan: None, + warning: None, + icon_url: String::new(), + lines: vec![ + MetricLine::Progress { + label: "Standard".into(), + used: 1000.0, + limit: 5000.0, + format: ProgressFormat::Count { + suffix: "tokens".into(), + }, + resets_at: None, + period_duration_ms: None, + color: None, + }, + MetricLine::Progress { + label: "Premium".into(), + used: 200.0, + limit: 1000.0, + format: ProgressFormat::Count { + suffix: "tokens".into(), + }, + resets_at: None, + period_duration_ms: None, + color: None, + }, + ], + }; + let m = NormalizedMetricsMapper::from_output(&out); + assert_eq!(m.input_tokens, Some(1200)); + } + + #[test] + fn mock_provider_quota_summary_is_one_short_line() { + let lines: Vec = (0..10) + .map(|i| MetricLine::Progress { + label: format!("Line {i}"), + used: (i as f64) * 10.0, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: None, + period_duration_ms: None, + color: None, + }) + .collect(); + let out = PluginOutput { + provider_id: "mock".into(), + display_name: "Mock".into(), + plan: None, + warning: None, + icon_url: String::new(), + lines, + }; + let m = NormalizedMetricsMapper::from_output(&out); + let s = m.list_quota_summary.as_deref().unwrap_or(""); + assert!(s.contains("Demo chaos")); + assert!(s.contains("10")); + assert!(!s.contains("Line 0")); + } +} diff --git a/crates/openusage-win-launcher/Cargo.toml b/crates/openusage-win-launcher/Cargo.toml new file mode 100644 index 000000000..5afc0cbf4 --- /dev/null +++ b/crates/openusage-win-launcher/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "openusage-win-launcher" +version = "0.1.0" +edition = "2024" +description = "Tiny Windows stub: materialize WebView2Loader.dll then exec openusage_gui.exe (GNU MinGW cannot delay-load WebView2Loader in-process)." +publish = false + +[[bin]] +name = "openusage-win-launcher" +path = "src/main.rs" + +[dependencies] diff --git a/crates/openusage-win-launcher/build.rs b/crates/openusage-win-launcher/build.rs new file mode 100644 index 000000000..6eaecda39 --- /dev/null +++ b/crates/openusage-win-launcher/build.rs @@ -0,0 +1,162 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Stage `WebView2Loader.dll` into `OUT_DIR` for `include_bytes!(concat!(env!("OUT_DIR"), ...))`. +//! With `OPENUSAGE_ONEFILE=1`, also builds a tiny custom payload archive so the launcher can +//! be shipped as one self-extracting exe. + +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +fn main() { + let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR"); + let out_dir = Path::new(&out_dir); + let dll_dst = out_dir.join("webview2loader.dll"); + let payload_dst = out_dir.join("payload.bin"); + let payload_id_dst = out_dir.join("payload_id.txt"); + let target = env::var("TARGET").expect("TARGET"); + + if !target.contains("windows") { + fs::write(&dll_dst, [0u8]).expect("write placeholder webview2loader.dll"); + fs::write(&payload_dst, []).expect("write empty payload.bin"); + fs::write(&payload_id_dst, "empty").expect("write placeholder payload_id.txt"); + return; + } + + let root = env::var("CARGO_MANIFEST_DIR") + .map(|manifest| Path::new(&manifest).join("../..")) + .expect("CARGO_MANIFEST_DIR"); + let profile = env::var("PROFILE").unwrap_or_else(|_| "release".into()); + let target_dir = root.join("target").join(&target).join(&profile); + + let mut candidates: Vec = Vec::new(); + if let Ok(p) = env::var("OPENUSAGE_WEBVIEW2_LOADER_SRC") { + candidates.push(PathBuf::from(p)); + } + candidates.push(target_dir.join("WebView2Loader.dll")); + + let mut copied = false; + for src in &candidates { + if src.is_file() { + fs::copy(src, &dll_dst) + .unwrap_or_else(|e| panic!("copy {} -> {}: {e}", src.display(), dll_dst.display())); + println!("cargo:rerun-if-changed={}", src.display()); + copied = true; + break; + } + } + + if !copied { + panic!( + "WebView2Loader.dll not found for openusage-win-launcher (target={target}).\n\ + Build the Tauri app for this Windows target first (produces target/.../release/WebView2Loader.dll for GNU), or set OPENUSAGE_WEBVIEW2_LOADER_SRC.\n\ + Tried: {:?}", + candidates + ); + } + + if env::var("OPENUSAGE_ONEFILE").ok().as_deref() == Some("1") { + write_payload(&root, &target_dir, &payload_dst, &payload_id_dst) + .expect("write onefile payload"); + } else { + fs::write(&payload_dst, []).expect("write empty payload.bin"); + fs::write(&payload_id_dst, "directory").expect("write payload_id.txt"); + } +} + +fn write_payload( + root: &Path, + target_dir: &Path, + payload_dst: &Path, + payload_id_dst: &Path, +) -> io::Result<()> { + let mut files = Vec::new(); + push_required( + &mut files, + target_dir.join("openusage.exe"), + "openusage_gui.exe", + ); + push_required( + &mut files, + target_dir.join("openusage-cli.exe"), + "openusage-cli.exe", + ); + push_required( + &mut files, + target_dir.join("WebView2Loader.dll"), + "WebView2Loader.dll", + ); + push_required( + &mut files, + root.join("src-tauri/resources/WINDOWS-PORTABLE.txt"), + "README-Windows.txt", + ); + + let resources = root.join("src-tauri/resources"); + add_dir(&resources, Path::new("resources"), &mut files)?; + + // Tauri resolves configured resources from the resource root, so this must be `icons/...` + // beside `resources/...`, not nested under `resources/icons`. + let icons = root.join("src-tauri/icons"); + add_dir(&icons, Path::new("icons"), &mut files)?; + + files.sort_by(|a, b| a.1.cmp(&b.1)); + + let mut payload = Vec::new(); + payload.extend_from_slice(b"CUOF1"); + payload.extend_from_slice(&(files.len() as u32).to_le_bytes()); + + for (src, archive_path) in files { + let data = fs::read(&src)?; + let path_bytes = archive_path.as_bytes(); + if path_bytes.len() > u16::MAX as usize { + panic!("payload path too long: {archive_path}"); + } + payload.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes()); + payload.extend_from_slice(&(data.len() as u64).to_le_bytes()); + payload.extend_from_slice(path_bytes); + payload.extend_from_slice(&data); + println!("cargo:rerun-if-changed={}", src.display()); + } + + let hash = fnv1a64(&payload); + fs::write(payload_dst, payload)?; + fs::write(payload_id_dst, format!("{hash:016x}"))?; + Ok(()) +} + +fn push_required(files: &mut Vec<(PathBuf, String)>, src: PathBuf, archive_path: &str) { + if !src.is_file() { + panic!("required onefile payload input missing: {}", src.display()); + } + files.push((src, archive_path.to_string())); +} + +fn add_dir( + src_dir: &Path, + archive_dir: &Path, + files: &mut Vec<(PathBuf, String)>, +) -> io::Result<()> { + let mut entries = fs::read_dir(src_dir)?.collect::, _>>()?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + let path = entry.path(); + let archive_path = archive_dir.join(entry.file_name()); + if path.is_dir() { + add_dir(&path, &archive_path, files)?; + } else if path.is_file() { + files.push((path, archive_path.to_string_lossy().replace('\\', "/"))); + } + } + Ok(()) +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for byte in bytes { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} diff --git a/crates/openusage-win-launcher/src/main.rs b/crates/openusage-win-launcher/src/main.rs new file mode 100644 index 000000000..7b82da3a4 --- /dev/null +++ b/crates/openusage-win-launcher/src/main.rs @@ -0,0 +1,187 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Windows portable entry. In directory mode it writes `WebView2Loader.dll` next to this exe, then +//! runs sibling `openusage_gui.exe`. In onefile mode it extracts the GUI, DLL, CLI, and resources +//! from an embedded payload into a stable per-user app directory first, then runs the extracted GUI. + +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] + +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; + +static WEBVIEW2_LOADER_DLL: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/webview2loader.dll")); +static ONEFILE_PAYLOAD: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/payload.bin")); +static ONEFILE_PAYLOAD_ID: &str = include_str!(concat!(env!("OUT_DIR"), "/payload_id.txt")); + +fn main() -> io::Result<()> { + let dir = if ONEFILE_PAYLOAD.is_empty() { + prepare_directory_mode()? + } else { + prepare_onefile_mode()? + }; + + run_gui(&dir) +} + +fn prepare_directory_mode() -> io::Result { + let exe = env::current_exe()?; + let dir = exe + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "current_exe has no parent"))? + .to_path_buf(); + + write_if_missing_or_empty(&dir.join("WebView2Loader.dll"), WEBVIEW2_LOADER_DLL)?; + Ok(dir) +} + +fn prepare_onefile_mode() -> io::Result { + let base = onefile_extract_root().join(ONEFILE_PAYLOAD_ID.trim()); + extract_payload(&base, ONEFILE_PAYLOAD)?; + Ok(base) +} + +fn onefile_extract_root() -> PathBuf { + #[cfg(target_os = "windows")] + { + if let Some(base) = env::var_os("LOCALAPPDATA").or_else(|| env::var_os("APPDATA")) { + return PathBuf::from(base).join("OpenUsage").join("onefile"); + } + + if let Ok(exe) = env::current_exe() { + if let Some(parent) = exe.parent() { + return parent.join("OpenUsage.onefile"); + } + } + } + + env::temp_dir().join("OpenUsage").join("onefile") +} + +fn run_gui(dir: &Path) -> io::Result<()> { + let gui = dir.join("openusage_gui.exe"); + if !gui.is_file() { + #[cfg(debug_assertions)] + eprintln!("openusage-win-launcher: missing {}", gui.display()); + std::process::exit(2); + } + + let mut cmd = Command::new(&gui); + cmd.args(env::args_os().skip(1)); + cmd.current_dir(dir); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + // Avoid a brief console flash when double-clicking from Explorer. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + let status = cmd.status()?; + std::process::exit(status.code().unwrap_or(1)); +} + +fn extract_payload(dir: &Path, payload: &[u8]) -> io::Result<()> { + let mut cursor = Cursor::new(payload); + if cursor.read_bytes(5)? != b"CUOF1" { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid openusage onefile payload", + )); + } + + let file_count = cursor.read_u32()?; + for _ in 0..file_count { + let path_len = cursor.read_u16()? as usize; + let data_len = cursor.read_u64()? as usize; + let path_bytes = cursor.read_bytes(path_len)?; + let archive_path = std::str::from_utf8(path_bytes).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid path utf8: {e}"), + ) + })?; + let file_data = cursor.read_bytes(data_len)?; + let target = safe_join(dir, archive_path)?; + + if target.is_file() && fs::metadata(&target)?.len() == data_len as u64 { + continue; + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, file_data)?; + } + Ok(()) +} + +fn safe_join(base: &Path, archive_path: &str) -> io::Result { + let mut out = base.to_path_buf(); + for part in archive_path.split('/') { + if part.is_empty() || part == "." || part == ".." || part.contains('\\') { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsafe payload path: {archive_path}"), + )); + } + out.push(part); + } + Ok(out) +} + +fn write_if_missing_or_empty(path: &Path, data: &[u8]) -> io::Result<()> { + if !path.is_file() || fs::metadata(path)?.len() == 0 { + fs::write(path, data)?; + } + Ok(()) +} + +struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn read_bytes(&mut self, len: usize) -> io::Result<&'a [u8]> { + let end = self + .offset + .checked_add(len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "payload offset overflow"))?; + if end > self.bytes.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated openusage onefile payload", + )); + } + let slice = &self.bytes[self.offset..end]; + self.offset = end; + Ok(slice) + } + + fn read_u16(&mut self) -> io::Result { + let bytes = self.read_bytes(2)?; + Ok(u16::from_le_bytes([bytes[0], bytes[1]])) + } + + fn read_u32(&mut self) -> io::Result { + let bytes = self.read_bytes(4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + fn read_u64(&mut self) -> io::Result { + let bytes = self.read_bytes(8)?; + Ok(u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ])) + } +} diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 80dfc8590..d1ee1f8b6 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -58,7 +58,7 @@ Unknown routes return **404 Not Found**. } ``` -The `lines` array uses the same metric line types as the internal plugin output: `progress`, `text`, and `badge`. +The `lines` array uses the same metric line types as the internal plugin output: `progress`, `text`, `badge`, and `barChart`. `fetchedAt` is an ISO 8601 timestamp indicating when the snapshot was last successfully fetched. diff --git a/docs/plugins/api.md b/docs/plugins/api.md index a6d56c62d..2c4586d79 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -153,7 +153,7 @@ Reads an environment variable by name. - Returns variable value as string when set - Returns `null` when missing -- Variable must be whitelisted first in `src-tauri/src/plugin_engine/host_api.rs` +- Variable must be whitelisted first in `crates/openusage-core/src/plugin_engine/host_api.rs` - Resolution order: current process env first, then a login+interactive shell lookup (macOS) - Values may be cached for the app session; restart OpenUsage after changing shell config diff --git a/package.json b/package.json index 7742dee53..fe0efa952 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,13 @@ "dev": "vite", "build": "tsc && vite build", "build:release": "./scripts/build-release.sh", + "build:all-artifacts": "./scripts/build-all-artifacts.sh", + "release:collect": "./scripts/collect-release-artifacts.sh", + "release:cli-zip-windows-gnu": "./scripts/build-cli-zip-windows-gnu.sh", + "release:gui-portable-zip-windows-gnu": "./scripts/build-gui-portable-zip-windows-gnu.sh", + "release:gui-portable-onefile-windows-gnu": "./scripts/build-gui-portable-onefile-windows-gnu.sh", + "release:gui-portable-onefile-windows-gnu:unique": "env OPENUSAGE_UNIQUE_ONEFILE=1 ./scripts/build-gui-portable-onefile-windows-gnu.sh", + "cli": "cargo run -p openusage-cli --", "ccusage:bump": "bun ./scripts/bump-ccusage-version.mjs", "bundle:plugins": "bun copy-bundled.cjs", "preview": "vite preview", diff --git a/scripts/build-all-artifacts.sh b/scripts/build-all-artifacts.sh new file mode 100755 index 000000000..c974dd89a --- /dev/null +++ b/scripts/build-all-artifacts.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Build Linux (.deb, .rpm, .AppImage) + Windows (.exe + NSIS setup) from a Linux host. +# Requires: bun, Rust, NSIS (makensis) for the Windows installer bundle. +set -euo pipefail +cd "$(dirname "$0")/.." +unset CI +# shellcheck disable=SC1091 +source "$(dirname "$0")/load-tauri-signing.sh" +# Some runners set CI=1; `bun run tauri build` can mis-parse that. Prefer: bunx tauri build ... + +echo "==> Linux bundles (deb, rpm, appimage)" +NO_STRIP=true bun run tauri build --bundles deb,rpm,appimage + +echo "==> Windows (GNU cross-target: openusage.exe + NSIS setup)" +bun run tauri build --target x86_64-pc-windows-gnu + +echo "" +echo "Outputs (workspace target/ at repo root; productName OpenUsage + version):" +echo " deb: target/release/bundle/deb/OpenUsage_*_amd64.deb (includes openusage + openusage-cli)" +echo " rpm: target/release/bundle/rpm/OpenUsage-*.rpm" +echo " appimage: target/release/bundle/appimage/OpenUsage_*_amd64.AppImage" +echo " win exe: target/x86_64-pc-windows-gnu/release/openusage.exe" +echo " win dll: target/x86_64-pc-windows-gnu/release/WebView2Loader.dll (keep next to openusage.exe for portable GNU builds)" +echo " win setup: target/x86_64-pc-windows-gnu/release/bundle/nsis/OpenUsage_*_x64-setup.exe" +echo "" +echo "Portable archives (optional, after this script or a normal tauri build):" +echo " Linux GUI .tar.gz: ./scripts/build-gui-portable-linux-tarball.sh" +echo " Windows GUI .zip: .\\scripts\\build-gui-portable-windows.ps1 (on Windows)" +echo " Windows GUI .zip: ./scripts/build-gui-portable-zip-windows-gnu.sh (on Linux, after step above for win target)" +echo " Windows CLI-only .zip: ./scripts/build-cli-zip-windows-gnu.sh (CLI + plugins, no openusage.exe)" +echo "Optional: copy everything into release-artifacts/ with:" +echo " ./scripts/collect-release-artifacts.sh" diff --git a/scripts/build-cli-windows.ps1 b/scripts/build-cli-windows.ps1 new file mode 100644 index 000000000..6800ec7d3 --- /dev/null +++ b/scripts/build-cli-windows.ps1 @@ -0,0 +1,62 @@ +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Build a portable Windows CLI bundle: openusage-cli.exe + resources/bundled_plugins +# (same layout as scripts/build-cli-tarball.sh on Linux/macOS, but .zip for INSTALL_MODE=cli on Windows). +# +# Run from repo root in PowerShell: +# .\scripts\build-cli-windows.ps1 +# +# Writes: +# openusage-cli__windows_.zip (repo root) +# copies into releases\ for git (optional) +# +# Requires: Rust toolchain for current host (x64 or ARM64 Windows). +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $Root + +$pkg = Get-Content -Raw (Join-Path $Root "package.json") | ConvertFrom-Json +$Version = [string]$pkg.version.Trim() + +$arch = $env:PROCESSOR_ARCHITECTURE +switch ($arch) { + "AMD64" { $Tag = "amd64" } + "ARM64" { $Tag = "arm64" } + default { throw "Unsupported PROCESSOR_ARCHITECTURE=$arch (need AMD64 or ARM64)" } +} + +Write-Host "==> Building openusage-cli (release) on Windows $Tag" +cargo build --release -p openusage-cli +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$exeSrc = Join-Path $Root "target\release\openusage-cli.exe" +if (-not (Test-Path -LiteralPath $exeSrc)) { + throw "Expected $exeSrc after cargo build" +} + +$stage = Join-Path $env:TEMP ("openusage-cli-stage-" + [guid]::NewGuid().ToString()) +$rootStage = Join-Path $stage "root" +New-Item -ItemType Directory -Path (Join-Path $rootStage "resources") -Force | Out-Null +Copy-Item -LiteralPath $exeSrc -Destination (Join-Path $rootStage "openusage-cli.exe") -Force +$bundled = Join-Path $Root "src-tauri\resources\bundled_plugins" +if (-not (Test-Path -LiteralPath $bundled)) { + throw "Missing $bundled — run from full repo checkout with plugins." +} +Copy-Item -LiteralPath $bundled -Destination (Join-Path $rootStage "resources\bundled_plugins") -Recurse -Force + +$zipName = "openusage-cli_${Version}_windows_${Tag}.zip" +$zipPath = Join-Path $Root $zipName +if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } +Compress-Archive -Path "$rootStage\*" -DestinationPath $zipPath -Force + +Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "==> Wrote $zipPath" +Get-Item $zipPath | Select-Object Name, Length + +$rel = Join-Path $Root "releases" +if (Test-Path -LiteralPath $rel) { + Copy-Item -LiteralPath $zipPath -Destination (Join-Path $rel $zipName) -Force + $legacy = Join-Path $rel "openusage-cli_windows_${Tag}.zip" + Copy-Item -LiteralPath $zipPath -Destination $legacy -Force + Write-Host "==> Copied to releases\ (and legacy openusage-cli_windows_${Tag}.zip)" +} diff --git a/scripts/build-cli-zip-windows-gnu.sh b/scripts/build-cli-zip-windows-gnu.sh new file mode 100755 index 000000000..64206f578 --- /dev/null +++ b/scripts/build-cli-zip-windows-gnu.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Portable **CLI** only (Windows .zip) from Linux: openusage-cli.exe + resources/bundled_plugins. +# For the full **GUI** portable zip (openusage.exe + openusage-cli + resources), use: +# scripts/build-gui-portable-zip-windows-gnu.sh (after tauri build --target x86_64-pc-windows-gnu) +# Same CLI layout as scripts/build-cli-windows.ps1 / install.ps1 INSTALL_MODE=cli. +# +# Prerequisites (Debian/Ubuntu example): +# sudo apt install -y mingw-w64 zip +# rustup target add x86_64-pc-windows-gnu +# +# Run from repo root: +# ./scripts/build-cli-zip-windows-gnu.sh +# +# Output: openusage-cli__windows_amd64.zip (+ optional copies under releases/) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TARGET="x86_64-pc-windows-gnu" +VERSION="$(node -p "require('./package.json').version")" + +if ! rustup target list --installed | grep -q "^${TARGET}\$"; then + echo "Missing Rust target ${TARGET}. Run: rustup target add ${TARGET}" >&2 + exit 1 +fi + +if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then + echo "Missing MinGW linker (x86_64-w64-mingw32-gcc). On Debian/Ubuntu: sudo apt install -y mingw-w64" >&2 + exit 1 +fi + +echo "==> Building openusage-cli (release) for ${TARGET}" +cargo build --release -p openusage-cli --target "${TARGET}" + +EXE="${ROOT}/target/${TARGET}/release/openusage-cli.exe" +if [[ ! -f "$EXE" ]]; then + echo "Expected ${EXE} after cargo build" >&2 + exit 1 +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "${STAGE}/root/resources" +cp -f "$EXE" "${STAGE}/root/openusage-cli.exe" +cp -a "${ROOT}/src-tauri/resources/bundled_plugins" "${STAGE}/root/resources/" + +OUT="${ROOT}/openusage-cli_${VERSION}_windows_amd64.zip" +rm -f "$OUT" +( + cd "${STAGE}/root" + if command -v zip >/dev/null 2>&1; then + zip -qr "$OUT" . + else + echo "Need zip(1). On Debian/Ubuntu: sudo apt install -y zip" >&2 + exit 1 + fi +) + +echo "==> Wrote $OUT" +ls -lh "$OUT" + +REL="${ROOT}/releases" +if [[ -d "$REL" ]]; then + cp -f "$OUT" "${REL}/openusage-cli_${VERSION}_windows_amd64.zip" + cp -f "$OUT" "${REL}/openusage-cli_windows_amd64.zip" + echo "==> Copied to releases/ (and legacy openusage-cli_windows_amd64.zip)" +fi diff --git a/scripts/build-gui-portable-onefile-windows-gnu.sh b/scripts/build-gui-portable-onefile-windows-gnu.sh new file mode 100755 index 000000000..acef3b55b --- /dev/null +++ b/scripts/build-gui-portable-onefile-windows-gnu.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Single-file portable Windows GUI from Linux. +# Builds one self-extracting openusage.exe that contains: +# openusage_gui.exe + openusage-cli.exe + WebView2Loader.dll + resources/ + icons/ +# +# By default this script runs `tauri build` first so it never packages a stale dev-style binary. +# Set OPENUSAGE_SKIP_TAURI_BUILD=1 only when you intentionally want to reuse target/ output. +# +# Output filename (repo root, copied to releases/ if present): +# openusage__windows_amd64_onefile.exe +# +# Avoid clobbering uploads / same-name conflicts: +# OPENUSAGE_ONEFILE_TAG=mybuild +# -> openusage__windows_amd64_onefile_mybuild.exe +# OPENUSAGE_UNIQUE_ONEFILE=1 +# -> openusage__windows_amd64_onefile__.exe +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TARGET="x86_64-pc-windows-gnu" +VERSION="$(node -p "require('./package.json').version")" + +SUFFIX="" +if [[ -n "${OPENUSAGE_ONEFILE_TAG:-}" ]]; then + # Sanitize for a filename (alphanumeric, dot, dash, underscore). + SAFE_TAG="$(printf '%s' "${OPENUSAGE_ONEFILE_TAG}" | tr -c 'A-Za-z0-9._-' '_')" + SUFFIX="_${SAFE_TAG}" +elif [[ "${OPENUSAGE_UNIQUE_ONEFILE:-0}" == "1" ]]; then + TS="$(date -u +%Y%m%dT%H%M%SZ)" + GIT="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo nogit)" + SUFFIX="_${TS}_${GIT}" +fi + +OUT="${ROOT}/openusage_${VERSION}_windows_amd64_onefile${SUFFIX}.exe" + +need_file() { + if [[ ! -f "$1" ]]; then + echo "Missing $1" >&2 + echo "Try again without OPENUSAGE_SKIP_TAURI_BUILD=1 so this script can run tauri build first." >&2 + exit 1 + fi +} + +if [[ "${OPENUSAGE_SKIP_TAURI_BUILD:-0}" != "1" ]]; then + bun run tauri build --target "${TARGET}" +else + echo "==> OPENUSAGE_SKIP_TAURI_BUILD=1 — rebuilding GUI with embedded frontend (custom-protocol) …" + bun run bundle:plugins + bun run build + cargo build --release -p openusage -p openusage-cli --target "${TARGET}" +fi + +need_file "${ROOT}/target/${TARGET}/release/openusage.exe" +need_file "${ROOT}/target/${TARGET}/release/openusage-cli.exe" +need_file "${ROOT}/target/${TARGET}/release/WebView2Loader.dll" + +if [[ ! -d "${ROOT}/src-tauri/resources/bundled_plugins" ]]; then + echo "Missing src-tauri/resources/bundled_plugins — run: bun run bundle:plugins" >&2 + exit 1 +fi + +OPENUSAGE_ONEFILE=1 cargo build --release -p openusage-win-launcher --target "${TARGET}" +cp -f "${ROOT}/target/${TARGET}/release/openusage-win-launcher.exe" "$OUT" + +echo "==> Wrote $OUT" +ls -lh "$OUT" + +REL="${ROOT}/releases" +if [[ -d "$REL" ]]; then + cp -f "$OUT" "${REL}/" + echo "==> Copied to releases/$(basename "$OUT")" +fi diff --git a/scripts/build-gui-portable-windows.ps1 b/scripts/build-gui-portable-windows.ps1 new file mode 100644 index 000000000..4a3b4ff1e --- /dev/null +++ b/scripts/build-gui-portable-windows.ps1 @@ -0,0 +1,89 @@ +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Portable **GUI** Windows bundle: **openusage.exe** (launcher when WebView2Loader.dll exists) + +# **openusage_gui.exe** (Tauri) + **openusage-cli.exe** + **WebView2Loader.dll** + **resources\** +# If the MSVC build does not emit WebView2Loader.dll next to the exe, we ship the Tauri binary as +# **openusage.exe** only (legacy layout). +# From Linux (GNU zip with launcher): scripts/build-gui-portable-zip-windows-gnu.sh after +# bun run tauri build --target x86_64-pc-windows-gnu +# Run from repo root after a release GUI build, e.g.: +# bun run tauri build +# Output: openusage__windows_.zip (repo root) +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +Set-Location $Root + +$pkg = Get-Content -Raw (Join-Path $Root "package.json") | ConvertFrom-Json +$Version = [string]$pkg.version.Trim() + +function Resolve-FirstPath { + param([string[]]$Patterns) + foreach ($pattern in $Patterns) { + $matches = Get-ChildItem -Path (Join-Path $Root $pattern) -File -ErrorAction SilentlyContinue | + Sort-Object FullName + if ($matches) { + return $matches[0].FullName + } + } + return $null +} + +switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { $Tag = "amd64" } + "ARM64" { $Tag = "arm64" } + default { throw "Unsupported PROCESSOR_ARCHITECTURE=$($env:PROCESSOR_ARCHITECTURE) (need AMD64 or ARM64)" } +} + +$gui = Resolve-FirstPath @("target\release\openusage.exe", "target\*\release\openusage.exe") +$cli = Resolve-FirstPath @("target\release\openusage-cli.exe", "target\*\release\openusage-cli.exe") +$wv2 = Resolve-FirstPath @("target\release\WebView2Loader.dll", "target\*\release\WebView2Loader.dll") +$res = Join-Path $Root "src-tauri\resources" +$icons = Join-Path $Root "src-tauri\icons" + +if (-not $gui -or -not (Test-Path -LiteralPath $gui)) { + throw "Missing openusage.exe under target\release or target\\release — run: bun run tauri build" +} +if (-not $cli -or -not (Test-Path -LiteralPath $cli)) { + throw "Missing openusage-cli.exe under target\release or target\\release — run: bun run tauri build (or cargo build --release -p openusage-cli)" +} +if (-not (Test-Path -LiteralPath (Join-Path $res "bundled_plugins"))) { + throw "Missing bundled_plugins — run: bun run bundle:plugins" +} + +$stage = Join-Path $env:TEMP ("openusage-gui-portable-" + [guid]::NewGuid().ToString()) +$rootStage = Join-Path $stage "root" +New-Item -ItemType Directory -Path $rootStage -Force | Out-Null + +Copy-Item -LiteralPath $cli -Destination (Join-Path $rootStage "openusage-cli.exe") -Force +Copy-Item -LiteralPath $res -Destination (Join-Path $rootStage "resources") -Recurse -Force +Copy-Item -LiteralPath $icons -Destination (Join-Path $rootStage "icons") -Recurse -Force +Copy-Item -LiteralPath (Join-Path $Root "src-tauri\resources\WINDOWS-PORTABLE.txt") -Destination (Join-Path $rootStage "README-Windows.txt") -Force + +if ($wv2 -and (Test-Path -LiteralPath $wv2)) { + Write-Host "==> Building openusage-win-launcher (WebView2Loader.dll present) …" + cargo build --release -p openusage-win-launcher + $launcher = Join-Path $Root "target\release\openusage-win-launcher.exe" + if (-not (Test-Path -LiteralPath $launcher)) { + throw "Missing $launcher after cargo build -p openusage-win-launcher" + } + Copy-Item -LiteralPath $gui -Destination (Join-Path $rootStage "openusage_gui.exe") -Force + Copy-Item -LiteralPath $launcher -Destination (Join-Path $rootStage "openusage.exe") -Force + Copy-Item -LiteralPath $wv2 -Destination (Join-Path $rootStage "WebView2Loader.dll") -Force +} else { + Write-Warning "Missing $wv2 — shipping single openusage.exe (MSVC layout; no embedded WebView2 loader stub)." + Copy-Item -LiteralPath $gui -Destination (Join-Path $rootStage "openusage.exe") -Force +} + +$zipName = "openusage_${Version}_windows_${Tag}.zip" +$zipPath = Join-Path $Root $zipName +if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force } +Compress-Archive -Path (Join-Path $rootStage "*") -DestinationPath $zipPath -Force +Remove-Item -LiteralPath $stage -Recurse -Force -ErrorAction SilentlyContinue + +Write-Host "==> Wrote $zipPath" +Get-Item $zipPath | Select-Object Name, Length + +$rel = Join-Path $Root "releases" +if (Test-Path -LiteralPath $rel) { + Copy-Item -LiteralPath $zipPath -Destination (Join-Path $rel $zipName) -Force + Write-Host "==> Copied to releases\" +} diff --git a/scripts/build-gui-portable-zip-windows-gnu.sh b/scripts/build-gui-portable-zip-windows-gnu.sh new file mode 100755 index 000000000..a56855dac --- /dev/null +++ b/scripts/build-gui-portable-zip-windows-gnu.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Portable **GUI** Windows bundle from Linux: **openusage.exe** (tiny launcher) + +# **openusage_gui.exe** (Tauri app) + **openusage-cli.exe** + **WebView2Loader.dll** + **resources/** +# The launcher writes WebView2Loader.dll from embedded bytes before starting the GUI, so the bundle +# still works when users copy only `openusage.exe` (the DLL is recreated next to it on first run). +# +# This is **not** the CLI-only zip — that is scripts/build-cli-zip-windows-gnu.sh (openusage-cli.exe only). +# +# Prerequisites (Debian/Ubuntu example): +# sudo apt install -y mingw-w64 zip +# rustup target add x86_64-pc-windows-gnu +# bun install +# +# 1) Build the Windows GUI (produces target/x86_64-pc-windows-gnu/release/openusage.exe + WebView2Loader.dll): +# bun run tauri build --target x86_64-pc-windows-gnu +# 2) Then: +# ./scripts/build-gui-portable-zip-windows-gnu.sh +# +# Output: openusage__windows_amd64.zip (repo root) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TARGET="x86_64-pc-windows-gnu" +VERSION="$(node -p "require('./package.json').version")" +TAG="amd64" + +if ! rustup target list --installed | grep -q "^${TARGET}\$"; then + echo "Missing Rust target ${TARGET}. Run: rustup target add ${TARGET}" >&2 + exit 1 +fi + +if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then + echo "Missing MinGW linker (x86_64-w64-mingw32-gcc). On Debian/Ubuntu: sudo apt install -y mingw-w64" >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "Need zip(1). On Debian/Ubuntu: sudo apt install -y zip" >&2 + exit 1 +fi + +TAURI_GUI="${ROOT}/target/${TARGET}/release/openusage.exe" +CLI="${ROOT}/target/${TARGET}/release/openusage-cli.exe" +WV2="${ROOT}/target/${TARGET}/release/WebView2Loader.dll" +RES="${ROOT}/src-tauri/resources" +ICONS="${ROOT}/src-tauri/icons" + +if [[ ! -f "$TAURI_GUI" ]]; then + echo "Missing $TAURI_GUI" >&2 + echo "Build the Windows GUI first, e.g.: bun run tauri build --target ${TARGET}" >&2 + exit 1 +fi + +if [[ ! -f "$WV2" ]]; then + echo "Missing $WV2 — required to embed into the portable launcher." >&2 + exit 1 +fi + +if [[ ! -f "$CLI" ]]; then + echo "Missing $CLI — building openusage-cli for ${TARGET} …" + cargo build --release -p openusage-cli --target "${TARGET}" +fi + +echo "==> Building openusage-win-launcher for ${TARGET} …" +cargo build --release -p openusage-win-launcher --target "${TARGET}" + +LAUNCHER="${ROOT}/target/${TARGET}/release/openusage-win-launcher.exe" +if [[ ! -f "$LAUNCHER" ]]; then + echo "Missing launcher output $LAUNCHER" >&2 + exit 1 +fi + +if [[ ! -d "$RES/bundled_plugins" ]]; then + echo "Missing $RES/bundled_plugins — run: bun run bundle:plugins" >&2 + exit 1 +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +mkdir -p "${STAGE}/root" +cp -f "$TAURI_GUI" "${STAGE}/root/openusage_gui.exe" +cp -f "$LAUNCHER" "${STAGE}/root/openusage.exe" +cp -f "$CLI" "${STAGE}/root/" +cp -f "$WV2" "${STAGE}/root/" +cp -a "$RES" "${STAGE}/root/resources" +cp -a "$ICONS" "${STAGE}/root/icons" +cp -f "${ROOT}/src-tauri/resources/WINDOWS-PORTABLE.txt" "${STAGE}/root/README-Windows.txt" + +OUT="${ROOT}/openusage_${VERSION}_windows_${TAG}.zip" +rm -f "$OUT" +( + cd "${STAGE}/root" + zip -qr "$OUT" . +) + +echo "==> Wrote $OUT" +ls -lh "$OUT" + +REL="${ROOT}/releases" +if [[ -d "$REL" ]]; then + cp -f "$OUT" "${REL}/" + echo "==> Copied to releases/" +fi diff --git a/scripts/build-release.sh b/scripts/build-release.sh index b00b2cf27..783b07c64 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -16,11 +16,11 @@ if [ -f "$TAURI_SIGNING_PRIVATE_KEY" ]; then fi # Clean previous bundle -rm -rf src-tauri/target/release/bundle +rm -rf target/release/bundle # Build -bun tauri build "$@" +NO_STRIP=true bun tauri build "$@" echo "" echo "✓ Build complete! Output:" -ls -la src-tauri/target/release/bundle/dmg/*.dmg 2>/dev/null || ls -la src-tauri/target/release/bundle/macos/*.app +ls -la target/release/bundle/dmg/*.dmg 2>/dev/null || ls -la target/release/bundle/macos/*.app diff --git a/scripts/bump-ccusage-version.mjs b/scripts/bump-ccusage-version.mjs index c5f97ba1a..441dd8ba1 100644 --- a/scripts/bump-ccusage-version.mjs +++ b/scripts/bump-ccusage-version.mjs @@ -21,7 +21,10 @@ if (!VERSION_RE.test(version)) { const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); -const hostApiPath = path.join(repoRoot, "src-tauri/src/plugin_engine/host_api.rs"); +const hostApiPath = path.join( + repoRoot, + "crates/openusage-core/src/plugin_engine/host_api.rs", +); const docsPath = path.join(repoRoot, "docs/plugins/api.md"); function replaceOnce(content, regex, replacement, missingMessage) { diff --git a/scripts/collect-release-artifacts.sh b/scripts/collect-release-artifacts.sh new file mode 100755 index 000000000..0bbb6c7e4 --- /dev/null +++ b/scripts/collect-release-artifacts.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# After `scripts/build-all-artifacts.sh`, copy bundles into ./release-artifacts// +# so filenames are easy to find (pattern is driven by tauri.conf.json productName + version). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +VERSION="$(node -p "require('./package.json').version")" +OUT="$ROOT/release-artifacts/openusage-${VERSION}" +mkdir -p "$OUT" + +echo "Collecting OpenUsage ${VERSION} bundles into $OUT" + +copy_glob () { + local pattern="$1" + shopt -s nullglob + local files=( $pattern ) + shopt -u nullglob + if [ ${#files[@]} -eq 0 ]; then + echo " (skip) no files: $pattern" + return 0 + fi + for f in "${files[@]}"; do + echo " + $(basename "$f")" + cp -f "$f" "$OUT/" + done +} + +# Linux (native host build) — workspace uses repo-root target/ +# Pin to ${VERSION}: the bundle dirs accumulate older *.deb/*.rpm from past builds. +copy_glob "$ROOT/target/release/bundle/deb/OpenUsage_${VERSION}_amd64.deb" +copy_glob "$ROOT/target/release/bundle/deb/openusage_${VERSION}_amd64.deb" +copy_glob "$ROOT/target/release/bundle/rpm/OpenUsage-${VERSION}-"*.rpm +copy_glob "$ROOT/target/release/bundle/rpm/openusage-${VERSION}-"*.rpm +copy_glob "$ROOT/target/release/bundle/appimage/OpenUsage_${VERSION}_amd64.AppImage" +copy_glob "$ROOT/target/release/bundle/appimage/openusage_${VERSION}_amd64.AppImage" + +# Windows (GNU cross-target) — MinGW Tauri binary delay-loads WebView2Loader.dll from the exe directory. +copy_glob "$ROOT/target/x86_64-pc-windows-gnu/release/bundle/nsis/OpenUsage_${VERSION}_x64-setup.exe" +copy_glob "$ROOT/target/x86_64-pc-windows-gnu/release/bundle/nsis/openusage_${VERSION}_x64-setup.exe" +copy_glob "$ROOT/target/x86_64-pc-windows-gnu/release/openusage.exe" +copy_glob "$ROOT/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll" + +# Single-file portable (optional — scripts/build-gui-portable-onefile-windows-gnu.sh) +copy_glob "$ROOT/openusage_${VERSION}_windows_amd64_onefile"*.exe + +# Portable Linux CLI (optional — run scripts/build-cli-tarball.sh on Linux amd64/arm64 first) +copy_glob "$ROOT/openusage-cli_${VERSION}_linux_"*.tar.gz +copy_glob "$ROOT/openusage-cli_${VERSION}_windows_"*.zip + +# Portable Linux GUI (optional — scripts/build-gui-portable-linux-tarball.sh after tauri build) +copy_glob "$ROOT/openusage_${VERSION}_linux_"*.tar.gz + +# Portable Windows GUI (optional — scripts/build-gui-portable-windows.ps1 on Windows after tauri build) +copy_glob "$ROOT/openusage_${VERSION}_windows_"*.zip + +# Portable macOS GUI (optional — scripts/build-gui-portable-macos-tarball.sh) +copy_glob "$ROOT/openusage_${VERSION}_darwin_"*.tar.gz + +cat > "$OUT/README.txt" << EOF +OpenUsage ${VERSION} — release artifacts +Fork: https://github.com/openusage-community/openusage +Upstream OpenUsage (Robin Ebers): https://github.com/robinebers/openusage + +Typical filenames (Tauri uses productName "OpenUsage" + version ${VERSION}): + - Debian: OpenUsage_${VERSION}_amd64.deb + - RPM: OpenUsage-${VERSION}-1.x86_64.rpm (release may vary) + - AppImage: OpenUsage_${VERSION}_amd64.AppImage + - Windows: OpenUsage_${VERSION}_x64-setup.exe (NSIS installer) + - Windows: openusage.exe + WebView2Loader.dll (same folder — required for GNU/MinGW portable exe) + - Windows: openusage_${VERSION}_windows_amd64_onefile*.exe (optional true single-file; see build-gui-portable-onefile-windows-gnu.sh) + - CLI: openusage-cli (same .deb / installer as GUI when built with prepare-cli-sidecar.sh) + - CLI tarball: openusage-cli_${VERSION}_linux_amd64.tar.gz, openusage-cli_${VERSION}_windows_amd64.zip (scripts/build-cli-tarball.sh / build-cli-zip-windows-gnu.sh) for INSTALL_MODE=cli + - Portable GUI: openusage_${VERSION}_linux_.tar.gz, openusage_${VERSION}_windows_.zip, openusage_${VERSION}_darwin_.tar.gz (see scripts/build-gui-portable-*.sh / .ps1) +EOF + +echo "Done. See $OUT/README.txt" diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 000000000..899abe695 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,321 @@ +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# OpenUsage — install from GitHub (Windows). +# Repo: https://github.com/openusage-community/openusage +# +# INSTALL_MODE=cli: portable CLI zip/tar.gz — tries the latest GitHub release first; only if missing, +# falls back to releases/ on the branch (raw.githubusercontent.com). Full mode uses NSIS/setup from that release. +# +# Usage (PowerShell): +# irm https://raw.githubusercontent.com/openusage-community/openusage/main/scripts/install.ps1 | iex +# +# Environment: +# $env:GITHUB_REPO default: openusage-community/openusage +# $env:INSTALL_MODE full (default) | cli — full = NSIS x64-setup.exe; cli = portable zip/tar.gz (binary + resources) like install.sh INSTALL_MODE=cli +# $env:INSTALL_RELEASE_TAG optional release tag; default latest release +# $env:INSTALL_GIT_REF branch or tag for raw.githubusercontent.com CLI bundle fallback (default: main) +# $env:INSTALL_CLI_URL optional override URL for the CLI .zip or .tar.gz (skips GitHub Release + branch fallbacks) +# $env:INSTALL_SILENT if "0" or "false", run NSIS installer interactively (full mode only; no effect in cli mode) + +$ErrorActionPreference = "Stop" + +$GithubRepo = if ($env:GITHUB_REPO) { $env:GITHUB_REPO } else { "openusage-community/openusage" } +$InstallReleaseTag = if ($env:INSTALL_RELEASE_TAG) { $env:INSTALL_RELEASE_TAG.Trim() } else { "latest" } +$InstallGitRef = if ($env:INSTALL_GIT_REF) { $env:INSTALL_GIT_REF } else { "main" } +$InstallMode = if ($env:INSTALL_MODE) { $env:INSTALL_MODE.Trim().ToLowerInvariant() } else { "full" } + +$headers = @{ + "User-Agent" = "OpenUsage-Install-Script" + "Accept" = "application/vnd.github+json" +} + +function Get-CliArchTag { + switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { return "amd64" } + "ARM64" { return "arm64" } + default { + Write-Error "INSTALL_MODE=cli: unsupported processor architecture '$($env:PROCESSOR_ARCHITECTURE)' (need AMD64 or ARM64)." + exit 1 + } + } +} + +function Get-RepoPackageVersion { + $url = "https://raw.githubusercontent.com/$GithubRepo/$InstallGitRef/package.json" + try { + $pkg = Invoke-RestMethod -Uri $url -Headers $headers + return [string]$pkg.version.Trim() + } catch { + return $null + } +} + +function Test-UrlOk { + param([string]$Url) + try { + $r = Invoke-WebRequest -Uri $Url -Method Head -Headers $headers -UseBasicParsing -TimeoutSec 30 + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 400) + } catch { + return $false + } +} + +function Download-File { + param([string]$Url, [string]$Dest) + Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing -Headers $headers +} + +function Get-ReleaseApiUrl { + if ($InstallReleaseTag -eq "latest") { + return "https://api.github.com/repos/$GithubRepo/releases/latest" + } + return "https://api.github.com/repos/$GithubRepo/releases/tags/$InstallReleaseTag" +} + +function Add-UserPathEntry { + param([string]$Dir) + if (-not (Test-Path -LiteralPath $Dir)) { + New-Item -ItemType Directory -Path $Dir -Force | Out-Null + } + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not $userPath) { $userPath = "" } + $parts = $userPath -split ";" | Where-Object { $_ -and $_.Trim() } + $norm = $Dir.TrimEnd("\") + $already = $false + foreach ($p in $parts) { + if ($p.TrimEnd("\").Equals($norm, [StringComparison]::OrdinalIgnoreCase)) { + $already = $true + break + } + } + if (-not $already) { + $newPath = if ($userPath) { "$userPath;$Dir" } else { $Dir } + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + } + if ($env:Path -notlike "*$Dir*") { + $env:Path = "$Dir;$env:Path" + } +} + +function Install-PortableCli { + $archTag = Get-CliArchTag + $repoBase = "https://raw.githubusercontent.com/$GithubRepo/$InstallGitRef/releases" + $ver = Get-RepoPackageVersion + + $versionedZip = $null + $versionedTgz = $null + if ($ver) { + $versionedZip = "$repoBase/openusage-cli_${ver}_windows_${archTag}.zip" + $versionedTgz = "$repoBase/openusage-cli_${ver}_windows_${archTag}.tar.gz" + } + $legacyZip = "$repoBase/openusage-cli_windows_${archTag}.zip" + $legacyTgz = "$repoBase/openusage-cli_windows_${archTag}.tar.gz" + + $tmp = Join-Path $env:TEMP ("openusage-cli-" + [guid]::NewGuid().ToString()) + $tmpZip = $tmp + ".zip" + $tmpTgz = $tmp + ".tar.gz" + + Write-Host "Downloading portable CLI bundle (windows $archTag) …" + + $gotFrom = $null + $downloaded = $false + + function Try-Download([string]$Url, [string]$Dest) { + try { + Download-File -Url $Url -Dest $Dest + return (Test-Path -LiteralPath $Dest) -and ((Get-Item $Dest).Length -gt 0) + } catch { + return $false + } + } + + # 1) Explicit URL override + if ($env:INSTALL_CLI_URL -and $env:INSTALL_CLI_URL.Trim()) { + $u = $env:INSTALL_CLI_URL.Trim() + if ($u -match "\.tar\.gz$") { + if (Try-Download $u $tmpTgz) { $downloaded = $true; $gotFrom = "override" } + } else { + if (Try-Download $u $tmpZip) { $downloaded = $true; $gotFrom = "override" } + } + } + + # 2) Pinned GitHub Release (same priority as scripts/install.sh INSTALL_MODE=cli) + if (-not $downloaded) { + Write-Host "Trying GitHub release $InstallReleaseTag …" + try { + $apiUrl = Get-ReleaseApiUrl + $release = Invoke-RestMethod -Uri $apiUrl -Headers $headers + $reZip = [regex]::new("openusage-cli_.+_windows_${archTag}\.zip$", "IgnoreCase") + $reTgz = [regex]::new("openusage-cli_.+_windows_${archTag}\.tar\.gz$", "IgnoreCase") + $asset = $release.assets | Where-Object { $reZip.IsMatch($_.name) } | Select-Object -First 1 + if (-not $asset) { + $asset = $release.assets | Where-Object { $reTgz.IsMatch($_.name) } | Select-Object -First 1 + } + if ($asset) { + if ($asset.name -match "\.tar\.gz$") { + Download-File -Url $asset.browser_download_url -Dest $tmpTgz + $gotFrom = "release-tgz" + } else { + Download-File -Url $asset.browser_download_url -Dest $tmpZip + $gotFrom = "release-zip" + } + $downloaded = $true + } + } catch { + Write-Host "Release fetch failed: $($_.Exception.Message)" + } + } + + # 3) Branch releases/ on raw.githubusercontent.com (versioned, then legacy) + if (-not $downloaded -and $versionedZip) { + Write-Host "Trying branch releases (versioned .zip) …" + if (Try-Download $versionedZip $tmpZip) { $downloaded = $true; $gotFrom = "versioned-zip" } + } + if (-not $downloaded -and $legacyZip) { + Write-Host "Trying branch releases (legacy .zip) …" + if (Try-Download $legacyZip $tmpZip) { $downloaded = $true; $gotFrom = "legacy-zip" } + } + if (-not $downloaded -and $versionedTgz) { + if (Try-Download $versionedTgz $tmpTgz) { $downloaded = $true; $gotFrom = "versioned-tgz" } + } + if (-not $downloaded -and $legacyTgz) { + if (Try-Download $legacyTgz $tmpTgz) { $downloaded = $true; $gotFrom = "legacy-tgz" } + } + + if (-not $downloaded) { + Write-Error @" +No CLI bundle found for windows_${archTag}. +Attach openusage-cli__windows_${archTag}.zip (or .tar.gz) to GitHub release $InstallReleaseTag, or add it under releases/ on branch $InstallGitRef (see scripts/build-cli-windows.ps1). +"@ + exit 1 + } + + if ($gotFrom -like "legacy-*" -and $ver) { + Write-Warning "Used legacy filename; ensure releases/openusage-cli_${ver}_windows_${archTag}.zip is committed on $InstallGitRef or upload the asset to release $InstallReleaseTag." + } + + $rootCli = Join-Path $env:USERPROFILE ".local\lib\openusage" + $binDir = Join-Path $env:USERPROFILE ".local\bin" + + if (Test-Path -LiteralPath (Join-Path $rootCli "openusage-cli.exe")) { + Write-Host "Existing portable CLI found — replacing binary and resources under $rootCli (reinstall / update)." + } else { + Write-Host "Installing portable CLI under $rootCli …" + } + + if (Test-Path -LiteralPath $rootCli) { + Remove-Item -LiteralPath $rootCli -Recurse -Force + } + New-Item -ItemType Directory -Path $rootCli -Force | Out-Null + + if (Test-Path -LiteralPath $tmpZip) { + Expand-Archive -LiteralPath $tmpZip -DestinationPath $rootCli -Force + Remove-Item -Force -ErrorAction SilentlyContinue $tmpZip + } elseif (Test-Path -LiteralPath $tmpTgz) { + $tar = Get-Command tar -ErrorAction SilentlyContinue + if (-not $tar) { + Write-Error "Need tar.exe to extract .tar.gz (included in Windows 10+). Or publish a .zip bundle instead." + exit 1 + } + & tar.exe -xzf $tmpTgz -C $rootCli + Remove-Item -Force -ErrorAction SilentlyContinue $tmpTgz + } else { + Write-Error "Download failed: no archive was saved." + exit 1 + } + + $exePath = Join-Path $rootCli "openusage-cli.exe" + if (-not (Test-Path -LiteralPath $exePath)) { + Write-Error "Bundle did not contain openusage-cli.exe at the root of the archive. Expected layout: openusage-cli.exe and resources\bundled_plugins\ (same as Linux tarball)." + exit 1 + } + + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $shim = Join-Path $binDir "openusage-cli.cmd" + $shimLines = @( + '@echo off', + 'setlocal', + 'set "OU_EXE=%USERPROFILE%\.local\lib\openusage\openusage-cli.exe"', + 'if not exist "%OU_EXE%" (echo openusage-cli: not installed at %OU_EXE% & exit /b 1)', + 'call "%OU_EXE%" %*', + 'exit /b %ERRORLEVEL%' + ) + Set-Content -LiteralPath $shim -Value ($shimLines -join "`r`n") -Encoding ascii + + Add-UserPathEntry -Dir $binDir + + Write-Host "Installed portable CLI: $exePath" + Write-Host "Shim on PATH: $shim" + Write-Host "" + Write-Host "Verifying ..." + $env:Path = "$binDir;$env:Path" + try { + & $exePath list 2>$null | Out-Null + if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq $null) { + Write-Host "openusage-cli list: ok" + } else { + Write-Warning "openusage-cli list: non-zero exit (check resources under $rootCli\resources)." + } + } catch { + Write-Warning "openusage-cli list: failed ($($_.Exception.Message))" + } + Write-Host "Done." +} + +# --- CLI-only portable bundle (parity with install.sh INSTALL_MODE=cli) --- +if ($InstallMode -eq "cli") { + Install-PortableCli + exit 0 +} + +if ($InstallMode -ne "full") { + Write-Error "Invalid INSTALL_MODE='$($env:INSTALL_MODE)' (use full or cli)." + exit 1 +} + +# --- Full install: NSIS GUI + bundled CLI (classic) --- +$apiUrl = Get-ReleaseApiUrl + +Write-Host "Fetching release $InstallReleaseTag from GitHub ($GithubRepo) ..." +$release = Invoke-RestMethod -Uri $apiUrl -Headers $headers + +$asset = $release.assets | Where-Object { $_.name -match 'x64-setup\.exe$' } | Select-Object -First 1 +if (-not $asset) { + Write-Error "No NSIS installer (*x64-setup.exe) found in release $InstallReleaseTag. For CLI-only (no installer), run:`n `$env:INSTALL_MODE='cli'; irm https://raw.githubusercontent.com/$GithubRepo/$InstallGitRef/scripts/install.ps1 | iex`nSee https://github.com/$GithubRepo/releases/tag/$InstallReleaseTag" + exit 1 +} + +$dest = Join-Path $env:TEMP ("openusage-setup-" + [guid]::NewGuid().ToString() + ".exe") +Write-Host "Downloading $($asset.name) ..." +Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $dest -UseBasicParsing -Headers $headers + +$silent = $true +if ($env:INSTALL_SILENT -match '^(0|false|no)$') { $silent = $false } + +if ($silent) { + Write-Host "Running installer silently (NSIS /S). Set `$env:INSTALL_SILENT=0 for interactive install." + $p = Start-Process -FilePath $dest -ArgumentList @("/S") -PassThru -Wait + if ($p.ExitCode -ne 0) { + Write-Warning "Installer exit code $($p.ExitCode). Try interactive: `$env:INSTALL_SILENT=0; then re-run this script." + } +} else { + Write-Host "Running installer (interactive) ..." + Start-Process -FilePath $dest -Wait +} + +Remove-Item -Force -ErrorAction SilentlyContinue $dest + +Write-Host "" +Write-Host "Verifying install ..." +$cli = Get-Command openusage-cli -ErrorAction SilentlyContinue +if ($cli) { + Write-Host "Found: $($cli.Source)" +} else { + Write-Warning "openusage-cli not on PATH yet. Open a new terminal or sign out/in; or add the install directory to PATH." +} + +$app = Get-Command openusage -ErrorAction SilentlyContinue +if ($app) { + Write-Host "Found: $($app.Source)" +} + +Write-Host "Done." diff --git a/scripts/prepare-cli-sidecar.sh b/scripts/prepare-cli-sidecar.sh new file mode 100755 index 000000000..11a721bf6 --- /dev/null +++ b/scripts/prepare-cli-sidecar.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +# Build openusage-cli for the same target as the Tauri app and place it where Tauri externalBin expects. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Tauri sets this for beforeBuildCommand (see TAURI_ENV_* in Tauri docs). +if [[ -n "${TAURI_ENV_TARGET_TRIPLE:-}" ]]; then + TARGET="$TAURI_ENV_TARGET_TRIPLE" +elif [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then + TARGET="$CARGO_BUILD_TARGET" +else + TARGET="$(rustc -vV | awk '/host:/{print $2}')" +fi + +echo "==> Building openusage-cli for $TARGET" +cargo build --release -p openusage-cli --target "$TARGET" + +mkdir -p "$ROOT/src-tauri/binaries" +if [[ "$TARGET" == *"windows"* ]]; then + SRC="$ROOT/target/$TARGET/release/openusage-cli.exe" + DEST="$ROOT/src-tauri/binaries/openusage-cli-$TARGET.exe" +else + SRC="$ROOT/target/$TARGET/release/openusage-cli" + DEST="$ROOT/src-tauri/binaries/openusage-cli-$TARGET" +fi + +cp -f "$SRC" "$DEST" +chmod +x "$DEST" 2>/dev/null || true +echo "==> CLI sidecar: $DEST" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 902d061cd..45910e9b1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +openusage-core = { path = "../crates/openusage-core" } tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } @@ -27,9 +28,7 @@ dirs = "6" log = "0.4" libc = "0.2" reqwest = { version = "0.13", features = ["blocking", "socks"] } -rquickjs = { version = "0.11", features = ["bindgen"] } tauri-plugin-store = "2.4.3" -base64 = "0.22" uuid = { version = "1", features = ["v4"] } tauri-plugin-log = "2" tauri-plugin-aptabase = { git = "https://github.com/aptabase/tauri-plugin-aptabase", rev = "e896cceb" } @@ -37,16 +36,17 @@ tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2.5.1" +tauri-plugin-positioner = { version = "2.3.1", features = ["tray-icon"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -regex-lite = "0.1.9" -aes-gcm = "0.10.3" -sha2 = "0.11" rust_xlsxwriter = "0.95.0" tauri-plugin-dialog = "2.7.1" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" +[target.'cfg(target_os = "windows")'.dependencies] +window-vibrancy = "0.5" + [target.'cfg(target_os = "macos")'.dependencies] # nspanel powers the floating menubar panel; macOS-only. tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a..e6f021cf7 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,45 @@ fn main() { + ensure_external_bin_for_tauri_metadata(); tauri_build::build() } + +fn ensure_external_bin_for_tauri_metadata() { + let Some(target) = std::env::var_os("TARGET") else { + return; + }; + let target = target.to_string_lossy(); + let exe_suffix = if target.contains("windows") { + ".exe" + } else { + "" + }; + let sidecar = + std::path::PathBuf::from("binaries").join(format!("openusage-cli-{target}{exe_suffix}")); + + if sidecar.exists() { + return; + } + + // Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage + // Tauri validates externalBin during `cargo check/test`, before its beforeBuildCommand can + // build the real sidecar. Create an ignored placeholder for metadata-only Cargo runs. + if let Some(parent) = sidecar.parent() { + let _ = std::fs::create_dir_all(parent); + } + let bytes: &[u8] = if target.contains("windows") { + b"" + } else { + b"#!/usr/bin/env sh\necho 'openusage-cli sidecar placeholder; run scripts/prepare-cli-sidecar.sh for bundles.' >&2\nexit 1\n" + }; + let _ = std::fs::write(&sidecar, bytes); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = std::fs::metadata(&sidecar) { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o755); + let _ = std::fs::set_permissions(&sidecar, permissions); + } + } +} diff --git a/src-tauri/resources/WINDOWS-PORTABLE.txt b/src-tauri/resources/WINDOWS-PORTABLE.txt new file mode 100644 index 000000000..6c1447eb8 --- /dev/null +++ b/src-tauri/resources/WINDOWS-PORTABLE.txt @@ -0,0 +1,32 @@ +Windows portable / manual copy +================================ + +**Official portable zip (GNU / Linux-built zip):** Run **openusage.exe** (small launcher). It +writes **WebView2Loader.dll** next to itself if missing, then starts **openusage_gui.exe** (the +real app). You can copy **openusage.exe** alone to another folder if you also copy +**openusage_gui.exe** (same folder); the launcher recreates the DLL on first run. + +**Older / MSVC zip (single exe):** Keep **WebView2Loader.dll** in the same folder as +**openusage.exe** when that DLL is shipped next to the exe. Without it, Windows may show: + + WebView2Loader.dll was not found + +Use the full zip from releases, or run the NSIS installer, or keep the required files together. + +You also need the Microsoft Edge WebView2 Runtime (normal on Windows 10/11). + +CLI resources +============= + +openusage-cli looks for bundled providers in this order: + +1. OPENUSAGE_RESOURCES, when set. Point it at a folder containing either: + - bundled_plugins/ + - resources/bundled_plugins/ +2. The portable zip layout next to openusage-cli.exe: + - resources/bundled_plugins/ + +For example: + + set OPENUSAGE_RESOURCES=C:\Tools\OpenUsage + openusage-cli.exe list diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae0a06106..391355d38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,15 +5,25 @@ mod error_logs; #[cfg(target_os = "linux")] mod gnome_window_anchor; mod local_http_api; +#[cfg(not(target_os = "windows"))] mod panel; mod panel_geometry; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] mod panel_non_macos; -mod plugin_engine; +#[cfg(target_os = "windows")] +mod panel_windows; +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +#[cfg(any(target_os = "linux", target_os = "windows"))] +mod popover_platform; mod tray; #[cfg(target_os = "macos")] mod webkit_config; +#[cfg(target_os = "windows")] +use panel_windows as panel; + +use openusage_core::plugin_engine; + use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -623,7 +633,7 @@ pub fn run() { error_logs::configure(app.handle())?; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "windows"))] panel::init(app.handle())?; let version = app.package_info().version.to_string(); @@ -650,7 +660,8 @@ pub fn run() { redacted_app_data_dir ); - let (_, plugins) = plugin_engine::initialize_plugins(&app_data_dir, &resource_dir); + let (_, plugins) = + plugin_engine::initialize_plugins(&app_data_dir, Some(&resource_dir)); let known_plugin_ids: Vec = plugins.iter().map(|p| p.manifest.id.clone()).collect(); app.manage(Mutex::new(AppState { diff --git a/src-tauri/src/local_http_api/cache.rs b/src-tauri/src/local_http_api/cache.rs index 845901774..47cc0b580 100644 --- a/src-tauri/src/local_http_api/cache.rs +++ b/src-tauri/src/local_http_api/cache.rs @@ -353,11 +353,15 @@ mod tests { provider_id: id.to_string(), display_name: name.to_string(), plan: Some("Pro".to_string()), + warning: None, lines: vec![MetricLine::Text { label: "Usage".to_string(), value: "42%".to_string(), color: None, subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, }], icon_url: String::new(), } diff --git a/src-tauri/src/local_http_api/usage_history.rs b/src-tauri/src/local_http_api/usage_history.rs index a65e3e7a3..9898d4151 100644 --- a/src-tauri/src/local_http_api/usage_history.rs +++ b/src-tauri/src/local_http_api/usage_history.rs @@ -279,6 +279,31 @@ fn row_from_line(snapshot: &UsageHistorySnapshot, line: MetricLine) -> ExportRow value: text, reset_at: String::new(), }, + MetricLine::BarChart { + label, + points, + note, + .. + } => { + let value = serde_json::json!({ + "points": points, + "note": note, + }) + .to_string(); + ExportRow { + fetched_at: snapshot.fetched_at.clone(), + provider_id: snapshot.provider_id.clone(), + provider_name: snapshot.display_name.clone(), + plan: snapshot.plan.clone().unwrap_or_default(), + line_type: "barChart".to_string(), + metric: label, + used: None, + limit: None, + unit: String::new(), + value, + reset_at: String::new(), + } + } } } @@ -555,6 +580,7 @@ mod tests { provider_id: id.to_string(), display_name: format!("Provider {}", id), plan: Some("Pro".to_string()), + warning: None, lines: vec![ MetricLine::Progress { label: "Session".to_string(), @@ -570,6 +596,9 @@ mod tests { value: "$12.34 · 56K tokens".to_string(), color: None, subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, }, ], icon_url: String::new(), @@ -581,6 +610,7 @@ mod tests { provider_id: id.to_string(), display_name: format!("Provider {}", id), plan: None, + warning: None, lines: vec![MetricLine::Badge { label: "Error".to_string(), text: "Failed".to_string(), @@ -641,11 +671,15 @@ mod tests { provider_id: "claude".to_string(), display_name: "Claude, Inc".to_string(), plan: Some("Team \"Pro\"".to_string()), + warning: None, lines: vec![MetricLine::Text { label: "Today".to_string(), value: "line one\nline two".to_string(), color: None, subtitle: None, + model_breakdown: None, + status_dot: None, + expiry_tooltip: None, }], icon_url: String::new(), }; diff --git a/src-tauri/src/panel_windows.rs b/src-tauri/src/panel_windows.rs new file mode 100644 index 000000000..802c0424b --- /dev/null +++ b/src-tauri/src/panel_windows.rs @@ -0,0 +1,78 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +use tauri::{AppHandle, Manager, PhysicalPosition, Position, Size}; + +use crate::popover_platform; + +/// Show the panel anchored to the tray when possible. +pub fn show_panel(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + popover_platform::move_main_near_tray(app_handle); + let _ = window.set_focus(); + } +} + +/// Hide the main window without exiting the tray app. +pub fn hide_panel(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } +} + +/// Toggle panel visibility (global shortcut). Positions near tray when opening. +pub fn toggle_panel(app_handle: &AppHandle) { + if let Some(window) = app_handle.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + log::debug!("toggle_panel: hiding window"); + let _ = window.hide(); + } else { + log::debug!("toggle_panel: showing window"); + let _ = window.show(); + popover_platform::move_main_near_tray(app_handle); + let _ = window.set_focus(); + } + } +} + +pub fn toggle_panel_at_tray_icon( + app_handle: &AppHandle, + _click_position: PhysicalPosition, + _icon_position: Position, + _icon_size: Size, +) { + toggle_panel(app_handle); +} + +pub fn init(app_handle: &tauri::AppHandle) -> tauri::Result<()> { + if let Some(window) = app_handle.get_webview_window("main") { + // Native window chrome (caption, taskbar, minimize) — not frameless popover. + let _ = window.set_decorations(true); + let _ = window.set_skip_taskbar(false); + let _ = window.set_always_on_top(false); + let _ = window.set_shadow(true); + let _ = window.set_minimizable(true); + let _ = window.set_maximizable(true); + let _ = window.set_closable(true); + let _ = window.set_resizable(true); + let _ = window.set_min_size(Some(tauri::Size::Logical(tauri::LogicalSize { + width: 400.0, + height: 480.0, + }))); + let _ = window.set_size(tauri::Size::Logical(tauri::LogicalSize { + width: 600.0, + height: 800.0, + })); + + let handle = app_handle.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + if let Some(w) = handle.get_webview_window("main") { + let _ = w.hide(); + } + } + }); + } + + Ok(()) +} diff --git a/src-tauri/src/plugin_engine/mod.rs b/src-tauri/src/plugin_engine/mod.rs deleted file mode 100644 index 6150e1e5c..000000000 --- a/src-tauri/src/plugin_engine/mod.rs +++ /dev/null @@ -1,118 +0,0 @@ -pub mod host_api; -pub mod manifest; -pub mod runtime; - -use manifest::LoadedPlugin; -use std::path::{Path, PathBuf}; - -pub fn initialize_plugins( - app_data_dir: &Path, - resource_dir: &Path, -) -> (PathBuf, Vec) { - if let Some(dev_dir) = find_dev_plugins_dir() { - if !is_dir_empty(&dev_dir) { - let plugins = manifest::load_plugins_from_dir(&dev_dir); - return (dev_dir, plugins); - } - } - - let install_dir = app_data_dir.join("plugins"); - if let Err(err) = std::fs::create_dir_all(&install_dir) { - log::warn!( - "failed to create install dir {}: {}", - install_dir.display(), - err - ); - } - - let bundled_dir = resolve_bundled_dir(resource_dir); - if bundled_dir.exists() { - copy_dir_recursive(&bundled_dir, &install_dir); - } - - let plugins = manifest::load_plugins_from_dir(&install_dir); - (install_dir, plugins) -} - -fn find_dev_plugins_dir() -> Option { - let cwd = std::env::current_dir().ok()?; - let direct = cwd.join("plugins"); - if direct.exists() { - return Some(direct); - } - let parent = cwd.join("..").join("plugins"); - if parent.exists() { - return Some(parent); - } - None -} - -fn resolve_bundled_dir(resource_dir: &Path) -> PathBuf { - let nested = resource_dir.join("resources/bundled_plugins"); - if nested.exists() { - nested - } else { - resource_dir.join("bundled_plugins") - } -} - -fn is_dir_empty(path: &Path) -> bool { - match std::fs::read_dir(path) { - Ok(mut entries) => entries.next().is_none(), - Err(err) => { - log::warn!("failed to read dir {}: {}", path.display(), err); - true - } - } -} - -fn copy_dir_recursive(src: &Path, dst: &Path) { - match std::fs::read_dir(src) { - Ok(entries) => { - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - log::warn!("failed to read entry in {}: {}", src.display(), err); - continue; - } - }; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - let file_type = match entry.file_type() { - Ok(file_type) => file_type, - Err(err) => { - log::warn!( - "failed to read file type for {}: {}", - src_path.display(), - err - ); - continue; - } - }; - if file_type.is_symlink() { - continue; - } - if file_type.is_dir() { - if let Err(err) = std::fs::create_dir_all(&dst_path) { - log::warn!("failed to create dir {}: {}", dst_path.display(), err); - continue; - } - copy_dir_recursive(&src_path, &dst_path); - } else if file_type.is_file() { - if let Err(err) = std::fs::copy(&src_path, &dst_path) { - log::warn!( - "failed to copy {} to {}: {}", - src_path.display(), - dst_path.display(), - err - ); - } - } - } - } - Err(err) => { - log::warn!("failed to read dir {}: {}", src.display(), err); - } - } -} diff --git a/src-tauri/src/popover_platform.rs b/src-tauri/src/popover_platform.rs new file mode 100644 index 000000000..48b38faba --- /dev/null +++ b/src-tauri/src/popover_platform.rs @@ -0,0 +1,126 @@ +// Windows support adapted from barramee27/crossusage (MIT): https://github.com/barramee27/crossusage +//! Window placement for tray-style popovers on Windows / Linux. +//! +//! **Linux (AppIndicator):** tray icon events often never populate `tauri-plugin-positioner`'s +//! internal state before the user opens the menu, and the plugin **panics** with +//! `"Tray position not set"` for `TrayCenter`. We **do not** use `TrayCenter` on Linux. +//! +//! **Windows:** `TrayCenter` is used when tray geometry exists (left-click path), with fallback. +//! +//! Call [`move_main_near_tray`] only **after** [`WebviewWindow::show`](tauri::WebviewWindow::show) +//! so `current_monitor()` is usually available. + +use tauri::{AppHandle, Manager}; + +#[cfg(target_os = "linux")] +use gtk::gdk::{self, prelude::*}; +#[cfg(any(target_os = "linux", target_os = "windows"))] +use tauri::{PhysicalPosition, Runtime, WebviewWindow}; + +/// Move the main window using tray geometry from `on_tray_event`, constrained to the work area. +/// Must run **after** `show()`. +pub fn move_main_near_tray(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + + if window.current_monitor().ok().flatten().is_none() { + log::debug!("popover: no current monitor; centering"); + let _ = window.center(); + return; + } + + #[cfg(target_os = "linux")] + { + place_linux_pointer_popover(&window); + return; + } + + #[cfg(target_os = "windows")] + { + use tauri_plugin_positioner::{Position, WindowExt}; + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + window.move_window_constrained(Position::TrayCenter) + })); + + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + log::debug!("popover: TrayCenter failed ({}), using monitor fallback", e); + place_windows_monitor_fallback(&window); + } + Err(_) => { + log::debug!("popover: TrayCenter unavailable; using monitor fallback"); + place_windows_monitor_fallback(&window); + } + } + } +} + +/// Linux AppIndicator does not reliably expose tray geometry, so anchor the popover near the +/// current pointer/menu position and clamp it to the monitor work area. +#[cfg(target_os = "linux")] +fn place_linux_pointer_popover(window: &WebviewWindow) { + let Ok(outer) = window.outer_size() else { + let _ = window.center(); + return; + }; + + let Some(display) = gdk::Display::default() else { + log::debug!("popover: no GDK display; centering"); + let _ = window.center(); + return; + }; + + let Some(pointer) = display.default_seat().and_then(|seat| seat.pointer()) else { + log::debug!("popover: no pointer device; centering"); + let _ = window.center(); + return; + }; + + let (_, pointer_x, pointer_y) = pointer.position(); + let Some(monitor) = display.monitor_at_point(pointer_x, pointer_y) else { + log::debug!("popover: no monitor at pointer; centering"); + let _ = window.center(); + return; + }; + + let workarea = monitor.workarea(); + let margin = 12i32; + let width = outer.width as i32; + let height = outer.height as i32; + let min_x = workarea.x() + margin; + let max_x = workarea.x() + workarea.width() - width - margin; + let x = (pointer_x - (width / 2)).clamp(min_x, max_x.max(min_x)); + + let workarea_mid_y = workarea.y() + (workarea.height() / 2); + let prefer_top_anchor = pointer_y <= workarea_mid_y; + let y = if prefer_top_anchor { + workarea.y() + margin + } else { + workarea.y() + workarea.height() - height - margin + }; + + let _ = window.set_position(PhysicalPosition::new(x, y)); +} + +/// Windows fallback when tray geometry is unavailable: place near the top edge of the +/// current monitor so it still reads as a tray popover. +#[cfg(target_os = "windows")] +fn place_windows_monitor_fallback(window: &WebviewWindow) { + match (window.current_monitor(), window.outer_size()) { + (Ok(Some(monitor)), Ok(outer)) => { + let pos = monitor.position(); + let size = monitor.size(); + let margin = 12i32; + let width = outer.width as i32; + let x = pos.x + ((size.width as i32 - width) / 2); + let y = pos.y + margin; + let _ = window.set_position(PhysicalPosition::new(x, y)); + } + _ => { + let _ = window.center(); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ffd71ac90..34176c73f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,12 +1,13 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenUsage", + "mainBinaryName": "openusage", "version": "0.6.37", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev", "devUrl": "http://localhost:1420", - "beforeBuildCommand": "bun run bundle:plugins && bun run build", + "beforeBuildCommand": "bun run bundle:plugins && bun run build && bash scripts/prepare-cli-sidecar.sh", "frontendDist": "../dist" }, "app": { @@ -30,6 +31,12 @@ "bundle": { "active": true, "targets": "all", + "publisher": "OpenUsage Community", + "homepage": "https://github.com/openusage-community/openusage", + "shortDescription": "OpenUsage Community tracks AI coding subscription usage from the menu bar or system tray.", + "longDescription": "OpenUsage Community is an independent, community-maintained continuation of the original OpenUsage project. Windows support includes code adapted from barramee27/crossusage.", + "copyright": "Original OpenUsage © Robin Ebers and contributors; Windows support adapted from barramee27/crossusage contributors", + "license": "MIT", "macOS": { "dmg": { "appPosition": { @@ -55,8 +62,17 @@ ], "resources": [ "resources/bundled_plugins/**/*", + "resources/WINDOWS-PORTABLE.txt", "icons/tray-icon.png" ], + "externalBin": [ + "binaries/openusage-cli" + ], + "windows": { + "nsis": { + "startMenuFolder": "OpenUsage" + } + }, "createUpdaterArtifacts": true }, "plugins": { diff --git a/vite.config.ts b/vite.config.ts index fb2668922..9870125f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig(async () => ({ environment: "jsdom", setupFiles: ["./src/test/setup.ts"], include: ["src/**/*.test.{ts,tsx}", "plugins/**/*.test.js"], - exclude: ["**/node_modules/**", "**/src-tauri/target/**"], + exclude: ["**/node_modules/**", "**/target/**", "**/src-tauri/target/**"], clearMocks: true, mockReset: true, restoreMocks: true,