Merge remote-tracking branch 'upstream/main' into fix/btw-scrollback
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Default build context filter, used by every build whose Dockerfile has no
|
||||
# `<dockerfile>.dockerignore` shadow next to it — today the install smokes in
|
||||
# scripts/install-tests (`podman build -f scripts/install-tests/*.dockerfile .`).
|
||||
# The pi and robomp images shadow this file with Dockerfile.dockerignore and
|
||||
# Dockerfile.robomp.dockerignore; keep the three roughly in sync.
|
||||
#
|
||||
# Patterns are depth-agnostic (`**/`) to match .gitignore semantics: dockerignore
|
||||
# anchors a bare `target/` at the context root only, so nested build dirs
|
||||
# (go-port/*/target, packages/*/dist) otherwise ride along — gigabytes per build.
|
||||
|
||||
# Heavy build outputs.
|
||||
**/target/
|
||||
bazel-*
|
||||
**/node_modules
|
||||
**/dist/
|
||||
runs/
|
||||
.xwin-cache/
|
||||
packages/coding-agent/binaries/
|
||||
packages/natives/npm/
|
||||
packages/natives/native/.build/
|
||||
**/.cache/
|
||||
packages/snapcompact/research/results/
|
||||
# Host-built addons: every image that needs one compiles it from source.
|
||||
**/*.node
|
||||
|
||||
# Per-host scratch the pi codebase uses for parallel agents / worktrees.
|
||||
.fallow/
|
||||
.worktrees/
|
||||
.wt/
|
||||
.opencode/
|
||||
.pi_config/
|
||||
.omp/plugins/
|
||||
|
||||
# VCS, editors, IDEs.
|
||||
.git/
|
||||
.npm/
|
||||
.vscode/
|
||||
.zed/
|
||||
.idea/
|
||||
|
||||
# OS + transient noise.
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.tmp
|
||||
|
||||
# Logs + profiling artifacts.
|
||||
*.log
|
||||
*.cpuprofile
|
||||
*.heapprofile
|
||||
*.heapsnapshot
|
||||
CPU.*
|
||||
|
||||
# Build / test side outputs.
|
||||
**/*.tsbuildinfo
|
||||
**/coverage/
|
||||
.nyc_output/
|
||||
**/__pycache__/
|
||||
compaction-results/
|
||||
changes/
|
||||
|
||||
# Generated, regenerated in-image.
|
||||
packages/ai/test/.temp-images/
|
||||
python/omp-rpc/src/omp_rpc.egg-info/
|
||||
python/robomp/data/
|
||||
python/robomp/.cache/
|
||||
python/robomp/src/robomp/static/
|
||||
python/robomp/web/dist/
|
||||
|
||||
# Scratch files the repo creates ad-hoc.
|
||||
syntax.jsonl
|
||||
out.jsonl
|
||||
out.html
|
||||
pi-*.html
|
||||
|
||||
# Host virtualenvs — the image installs its own interpreter deps.
|
||||
**/.venv/
|
||||
|
||||
# Secrets. Should never be in an image regardless. Depth-agnostic: a bare
|
||||
# `.env` misses nested ones such as `python/robomp/.env`.
|
||||
**/.env
|
||||
@@ -162,11 +162,11 @@ jobs:
|
||||
bazelisk --bazelrc="${{ steps.cache.outputs.rc }}" test //crates/...
|
||||
# Clippy scope mirrors `cargo clippy --workspace` (libraries only, no
|
||||
# test targets) plus the strict/default split: crates with
|
||||
# `[lints] workspace = true` get the workspace policy, the vendored
|
||||
# brush-core fork is exempt (same as run-rs-task.ts's cargo excludes).
|
||||
# `[lints] workspace = true` get the workspace policy, except
|
||||
# brush-core (a vendored fork excluded from the Cargo task too).
|
||||
# pi-builtins allows every clippy group in its own manifest (ported
|
||||
# brush/uutils/jaq code) but is still held to zero rustc warnings;
|
||||
# cargo honors that via `[lints]`, bazel via the clippy-ported config.
|
||||
# Cargo honors that via `[lints]`, Bazel via the clippy-ported config.
|
||||
- name: Clippy (workspace lint policy on opted-in crates)
|
||||
run: |
|
||||
bazelisk query "kind('rust_library|rust_shared_library', //crates/pi-ast/... + //crates/pi-iso/... + //crates/pi-natives/... + //crates/pi-shell/... + //crates/pi-voice/... + //crates/pi-walker/...)" \
|
||||
@@ -411,12 +411,6 @@ jobs:
|
||||
- name: Test coding-agent native/unit bucket
|
||||
env:
|
||||
OMP_TEST_CONCURRENCY: "4"
|
||||
# The mupdf/PDF-extraction chunk measures ~7 min on burstable
|
||||
# runners under a full 8-wide fan-out; the default 600 s chunk
|
||||
# watchdog SIGKILLed it (release run 30519992654). The watchdog
|
||||
# exists to catch wedged children, not slow-but-progressing
|
||||
# chunks — give this bucket a wider budget.
|
||||
OMP_TEST_CHUNK_TIMEOUT: "1200"
|
||||
run: bun run ci:test:coding-agent:native
|
||||
|
||||
test_smoke:
|
||||
|
||||
@@ -64,7 +64,6 @@ pi-*.html
|
||||
# Generated files
|
||||
packages/coding-agent/src/export/html/tool-views.generated.js
|
||||
packages/natives/npm/
|
||||
packages/coding-agent/src/utils/mupdf-wasm.wasm
|
||||
/runs/
|
||||
python/omp-rpc/src/omp_rpc.egg-info/
|
||||
python/omp-rpc/build/
|
||||
|
||||
Generated
+239
-94
@@ -1868,6 +1868,15 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "ecb"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -1979,6 +1988,29 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -2662,11 +2694,6 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2729,9 +2756,9 @@ checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
|
||||
|
||||
[[package]]
|
||||
name = "html-to-markdown-rs"
|
||||
version = "3.10.6"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0736be7417aaf65e02124703aa8db88ad42ceff95df99783d0d48bbed4da3a8"
|
||||
checksum = "8f3e479289c322f5982b930575658df32d1161d6fab80b4078ce3e692f491df5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"astral-tl",
|
||||
@@ -2739,7 +2766,6 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"html-escape",
|
||||
"html5ever",
|
||||
"lru",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"phf 0.14.0",
|
||||
@@ -2793,15 +2819,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45"
|
||||
checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
"icu_calendar_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"tinystr",
|
||||
@@ -2810,20 +2836,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_calendar_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d"
|
||||
checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collator"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bbdf98e5e0aa827770acee171ebb568aaab975a36b56afdb5fd0e2f525750bb"
|
||||
checksum = "59eea194d189ca897bcb960fd2130f9e7d53998460d4d32e85a60b10d5507cf4"
|
||||
dependencies = [
|
||||
"icu_collator_data",
|
||||
"icu_collections",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
@@ -2835,15 +2861,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collator_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0"
|
||||
checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -2855,17 +2881,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "989d56ea5bbc43ae2b4e0388874b002884eaf4ed3a76c84a6c8c5ad575e04d72"
|
||||
checksum = "c44df99c4c5297b05653d1a1629f13bdc9fd4044acfbbaf30b15194a1eed4cd6"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_calendar",
|
||||
"icu_datetime_data",
|
||||
"icu_decimal",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
@@ -2878,21 +2904,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_datetime_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40d3cc1b690d9703202bc319692ac8a1f3a6390686f0930ff40542450fa34f0b"
|
||||
checksum = "8bb5555f6e4b42233f58564351ce16d73e9e6ebfce6bdcf29875d0cfc554d6a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_decimal_data",
|
||||
"icu_locale",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"writeable",
|
||||
@@ -2901,30 +2927,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22"
|
||||
checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c"
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26"
|
||||
checksum = "250ec6d4ac288584d1d0ba29dbfad7737635b7b0e822441f4026a523e3fdb147"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_locale_data",
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
@@ -2936,15 +2962,35 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993"
|
||||
checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea"
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
@@ -2959,15 +3005,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c4c568054ffe735398a9f4c55aec37ad7c768844553cc0978f09cc9b933a1fb"
|
||||
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
@@ -2978,12 +3024,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_locale",
|
||||
"icu_locale_fallback",
|
||||
"icu_plurals_data",
|
||||
"icu_provider",
|
||||
"zerovec",
|
||||
@@ -2991,16 +3037,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d"
|
||||
checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
@@ -3011,15 +3058,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
@@ -3034,9 +3081,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_time"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec3af0c141da0a61d4f6970cd1d5f4b388b17ea22f8124f8f6049d3d5147586a"
|
||||
checksum = "af37e1c3b9f3ab564b619fdf4b22de169f6381eb0f7048e3ca454290c438126b"
|
||||
dependencies = [
|
||||
"calendrical_calculations",
|
||||
"displaydoc",
|
||||
@@ -3051,9 +3098,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_time_data"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e0ee79a0bb29772465234bfbad6ea04fbc5220bef9fa4f318cc53eb8897070e"
|
||||
checksum = "005ff86fa5851a77fc0a977b72d717f777bd9230415a247219d7898aefb1517a"
|
||||
|
||||
[[package]]
|
||||
name = "icy_sixel"
|
||||
@@ -3137,6 +3184,25 @@ dependencies = [
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -3606,9 +3672,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@@ -3626,12 +3692,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.2"
|
||||
name = "lopdf"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"aes",
|
||||
"bitflags 2.13.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"ecb",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"jiff",
|
||||
"log",
|
||||
"md-5",
|
||||
"nom 8.0.0",
|
||||
"rand 0.10.2",
|
||||
"rangemap",
|
||||
"rayon",
|
||||
"sha2",
|
||||
"stringprep",
|
||||
"thiserror 2.0.20",
|
||||
"time",
|
||||
"ttf-parser",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4533,6 +4621,24 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e024ae242c514e2adf6aee186678e0eabdc2e5ecfbb2159186881b4498593cb"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"include_dir",
|
||||
"log",
|
||||
"lopdf",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"regex",
|
||||
"thiserror 2.0.20",
|
||||
"ttf-parser",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peg"
|
||||
version = "0.8.6"
|
||||
@@ -4587,9 +4693,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
|
||||
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -4597,9 +4703,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
|
||||
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -4607,9 +4713,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
|
||||
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -4620,9 +4726,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
|
||||
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -4774,7 +4880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -4843,7 +4949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-builtins"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anyhow",
|
||||
@@ -4928,7 +5034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -4940,7 +5046,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -4976,6 +5082,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"pdf-inspector",
|
||||
"phf 0.13.1",
|
||||
"pi-ast",
|
||||
"pi-iso",
|
||||
@@ -5011,7 +5118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-core",
|
||||
@@ -5039,7 +5146,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-voice"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"audiopus_sys",
|
||||
"bytes",
|
||||
@@ -5054,7 +5161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-walker"
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"globset",
|
||||
@@ -5128,9 +5235,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "platform-info"
|
||||
@@ -5230,9 +5337,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
@@ -5499,6 +5606,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rangemap"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
@@ -6204,6 +6317,17 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -6502,9 +6626,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
@@ -7369,12 +7493,33 @@ version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -8545,9 +8690,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -8912,9 +9057,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
@@ -8924,9 +9069,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
@@ -8936,13 +9081,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+16
-2
@@ -1,9 +1,22 @@
|
||||
[workspace]
|
||||
members = ["crates/pi-*", "crates/vendor/*"]
|
||||
# Explicit, not `crates/pi-*`: a glob member that resolves to a directory
|
||||
# without a Cargo.toml (a deleted crate whose dir survives `git reset --hard`
|
||||
# because an ignored file stayed behind) is a hard error that fails the whole
|
||||
# workspace, not a skipped entry.
|
||||
members = [
|
||||
"crates/pi-ast",
|
||||
"crates/pi-builtins",
|
||||
"crates/pi-iso",
|
||||
"crates/pi-natives",
|
||||
"crates/pi-shell",
|
||||
"crates/pi-voice",
|
||||
"crates/pi-walker",
|
||||
"crates/vendor/brush-core",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "17.3.0"
|
||||
version = "17.3.4"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
@@ -216,6 +229,7 @@ clap = { version = "4", features = ["derive"] }
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Text Processing & Parsing
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
pdf-inspector = "1"
|
||||
regex = "1"
|
||||
similar = "3.1.0"
|
||||
unicode-segmentation = "1.13"
|
||||
|
||||
+20
-13
@@ -25,33 +25,41 @@
|
||||
ARG BUN_VERSION=1.3.14
|
||||
|
||||
############################
|
||||
# 1) natives-builder — Rust + Bun → pi_natives.linux-<arch>.node
|
||||
# 1) natives-builder — Rust + Bun → pi_natives.linux-<arch>.node (local cargo)
|
||||
############################
|
||||
FROM rust:1.86-slim-bookworm AS natives-builder
|
||||
|
||||
ARG BUN_VERSION
|
||||
|
||||
# The addon is built with cargo/napi-rs (OMP_NATIVE_BUILD_BACKEND=cargo)
|
||||
# instead of Bazel: the image is one fixed host target, so Bazel's hermetic
|
||||
# cross toolchains and crate_universe splice buy nothing while costing a
|
||||
# bazelisk download plus a full analysis phase on every build. `ci` profile =
|
||||
# release codegen, thin LTO, stripped.
|
||||
ENV BUN_INSTALL=/opt/bun \
|
||||
PATH=/opt/bun/bin:/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
CARGO_TERM_COLOR=never
|
||||
CARGO_TERM_COLOR=never \
|
||||
OMP_NATIVE_BUILD_BACKEND=cargo \
|
||||
OMP_NATIVE_CARGO_PROFILE=ci
|
||||
|
||||
# clang/libclang-dev: bindgen for pipewire-sys/libspa-sys (Linux desktop capture);
|
||||
# cmake/make/ninja-build: audiopus_sys builds bundled libopus via CMake.
|
||||
# bazelisk: hermetic bazel launcher for the native addon build (17.1.5+).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates pkg-config libssl-dev unzip git \
|
||||
clang libclang-dev cmake make ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL -o /usr/local/bin/bazelisk \
|
||||
"https://github.com/bazelbuild/bazelisk/releases/download/v1.25.0/bazelisk-linux-$(dpkg --print-architecture)" \
|
||||
&& chmod +x /usr/local/bin/bazelisk \
|
||||
&& ln -s /usr/local/bin/bazelisk /usr/local/bin/bazel
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
|
||||
&& /opt/bun/bin/bun --version
|
||||
|
||||
WORKDIR /pi
|
||||
|
||||
# Layer 0 — the pinned nightly toolchain. Its own layer so a source or manifest
|
||||
# edit never re-downloads ~5 rustup components.
|
||||
COPY rust-toolchain.toml /pi/
|
||||
RUN rustup show
|
||||
|
||||
# Layer 1 — manifests + lockfiles only. Source edits under packages/*/src and
|
||||
# crates/*/src won't bust `bun install` below. `--parents` preserves the
|
||||
# matched path under /pi/ (requires syntax 1.7-labs).
|
||||
@@ -75,13 +83,12 @@ RUN bun install --frozen-lockfile --ignore-scripts
|
||||
COPY . /pi/
|
||||
|
||||
# Layer 4 — compile pi-natives to a Linux N-API addon. Persistent caches keep
|
||||
# repeat builds incremental: cargo's package index + git-deps + the workspace
|
||||
# target dir.
|
||||
RUN --mount=type=cache,target=/root/.cargo/registry \
|
||||
--mount=type=cache,target=/root/.cargo/git \
|
||||
# repeat builds incremental: cargo's package index + git-deps (CARGO_HOME is
|
||||
# /usr/local/cargo in the rust image, not ~/.cargo) + the workspace target dir.
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/pi/target \
|
||||
set -eux; \
|
||||
rustup show; \
|
||||
bun --cwd=packages/natives run build; \
|
||||
mkdir -p /out; \
|
||||
cp packages/natives/native/pi_natives.linux-*.node /out/
|
||||
|
||||
+25
-11
@@ -2,13 +2,21 @@
|
||||
# .dockerignore for this file only. Robomp uses Dockerfile.robomp +
|
||||
# Dockerfile.robomp.dockerignore alongside.
|
||||
|
||||
# Heavy build outputs — must never reach the build context. `target/` alone is
|
||||
# >100 GB on a dev machine; `bazel-*` symlinks point at the bazel output base.
|
||||
target/
|
||||
# Heavy build outputs — must never reach the build context. Patterns are
|
||||
# depth-agnostic (`**/`) to match .gitignore semantics: dockerignore anchors
|
||||
# bare `target/`/`dist/` at the context root only, which leaked
|
||||
# packages/*/dist (~600 MB) and go-port/*/target (~1.4 GB) into every build.
|
||||
# `bazel-*` symlinks point at the bazel output base.
|
||||
**/target/
|
||||
bazel-*
|
||||
**/node_modules
|
||||
dist/
|
||||
**/dist/
|
||||
runs/
|
||||
.xwin-cache/
|
||||
packages/coding-agent/binaries/
|
||||
packages/natives/npm/
|
||||
**/.cache/
|
||||
packages/snapcompact/research/results/
|
||||
|
||||
# Per-host scratch the pi codebase uses for parallel agents / worktrees.
|
||||
.fallow/
|
||||
@@ -40,18 +48,20 @@ runs/
|
||||
CPU.*
|
||||
|
||||
# Build / test side outputs.
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
**/*.tsbuildinfo
|
||||
**/coverage/
|
||||
.nyc_output/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
compaction-results/
|
||||
changes/
|
||||
|
||||
# Generated files (the in-image build regenerates them).
|
||||
packages/coding-agent/src/internal-urls/docs-index.generated.ts
|
||||
packages/natives/native/.build/
|
||||
packages/natives/native/pi_natives.darwin-*.node
|
||||
packages/natives/native/pi_natives.dev.node
|
||||
# Every host-built addon: natives-builder compiles its own, and a stale host
|
||||
# `pi_natives.linux-*.node` riding in would be copied to /out alongside it.
|
||||
# (Also drops the ~760 MB of dev addons a working checkout accumulates.)
|
||||
**/*.node
|
||||
packages/ai/test/.temp-images/
|
||||
python/omp-rpc/src/omp_rpc.egg-info/
|
||||
python/robomp/data/
|
||||
@@ -65,5 +75,9 @@ out.jsonl
|
||||
out.html
|
||||
pi-*.html
|
||||
|
||||
# Secrets. Should never be in the image regardless.
|
||||
.env
|
||||
# Host virtualenvs — the image installs its own interpreter deps.
|
||||
**/.venv/
|
||||
|
||||
# Secrets. Should never be in the image regardless. Depth-agnostic: a bare
|
||||
# `.env` misses `python/robomp/.env`, which `COPY . /pi/` would bake in.
|
||||
**/.env
|
||||
|
||||
@@ -5,11 +5,23 @@
|
||||
# the cost of per-Dockerfile shadows (no shared file to factor common rules
|
||||
# into). Keep them roughly in sync.
|
||||
|
||||
# Heavy build outputs — must never reach the build context.
|
||||
target/
|
||||
# Heavy build outputs — must never reach the build context. Depth-agnostic for
|
||||
# the same reason as Dockerfile.dockerignore: bare `target/`/`dist/` only match
|
||||
# the context root, so nested build dirs (go-port/*/target, packages/*/dist)
|
||||
# were shipped on every build.
|
||||
**/target/
|
||||
bazel-*
|
||||
**/node_modules
|
||||
dist/
|
||||
**/dist/
|
||||
runs/
|
||||
.xwin-cache/
|
||||
packages/coding-agent/binaries/
|
||||
packages/natives/npm/
|
||||
packages/natives/native/
|
||||
**/.cache/
|
||||
packages/snapcompact/research/results/
|
||||
# The addon comes from PI_BASE; nothing in this context needs one.
|
||||
**/*.node
|
||||
|
||||
# Per-host scratch the pi codebase uses for parallel agents / worktrees.
|
||||
.fallow/
|
||||
@@ -66,8 +78,12 @@ out.jsonl
|
||||
out.html
|
||||
pi-*.html
|
||||
|
||||
# Secrets. Should never be in the image regardless.
|
||||
.env
|
||||
# Host virtualenvs — the image installs its own interpreter deps.
|
||||
**/.venv/
|
||||
|
||||
# Secrets. Should never be in the image regardless. Depth-agnostic: a bare
|
||||
# `.env` misses `python/robomp/.env`, which sits next to the copied src tree.
|
||||
**/.env
|
||||
|
||||
# Robomp-only excludes. Natives + wheel + python + bun + rustup all come
|
||||
# from PI_BASE; the web-builder stage only needs root manifests + the
|
||||
|
||||
Generated
+596
-425
File diff suppressed because one or more lines are too long
+14
-1
@@ -16,19 +16,32 @@ _ADDON_RUSTC_FLAGS = [
|
||||
]
|
||||
|
||||
def _addon_transition_impl(settings, attr):
|
||||
# Statically link the MSVC CRT for the shipped win32 addon: rustc gets
|
||||
# +crt-static via the crate's rustc_flags select, and the C dependencies
|
||||
# (opus/cmake, tree-sitter, blake3, ring) must move to /MT in lock-step so
|
||||
# the final .node imports no VCRUNTIME140.dll from the Visual C++
|
||||
# Redistributable (absent on a clean Windows install -> dlopen error 126).
|
||||
# The static_link_msvcrt cc feature flips the toolchain compile flags that
|
||||
# rules_rust forwards to cc-rs/cmake as CFLAGS/CXXFLAGS; it is inert for the
|
||||
# zig/darwin toolchains, so scoping it to win32 is belt-and-suspenders.
|
||||
features = list(settings["//command_line_option:features"])
|
||||
if "win32" in str(attr.platform):
|
||||
features = features + ["static_link_msvcrt"]
|
||||
return {
|
||||
"//command_line_option:platforms": str(attr.platform),
|
||||
"//command_line_option:compilation_mode": "opt",
|
||||
"//command_line_option:features": features,
|
||||
"@rules_rust//rust/settings:lto": "thin",
|
||||
"@rules_rust//rust/settings:extra_rustc_flags": _ADDON_RUSTC_FLAGS,
|
||||
}
|
||||
|
||||
_addon_transition = transition(
|
||||
implementation = _addon_transition_impl,
|
||||
inputs = [],
|
||||
inputs = ["//command_line_option:features"],
|
||||
outputs = [
|
||||
"//command_line_option:platforms",
|
||||
"//command_line_option:compilation_mode",
|
||||
"//command_line_option:features",
|
||||
"@rules_rust//rust/settings:lto",
|
||||
"@rules_rust//rust/settings:extra_rustc_flags",
|
||||
],
|
||||
|
||||
@@ -22,9 +22,16 @@ exec hosts. Replaces cargo-xwin.
|
||||
work from Bazel actions (cwd = execroot) *and* from build scripts, where
|
||||
rules_rust `${pwd}`-expands `CC`/`AR` to absolute paths and cc-rs/cmake spawn
|
||||
tools from other cwds.
|
||||
- **CRT: dynamic `/MD`** (rules_cc's msvc branch default outside `dbg` without
|
||||
the `static_link_msvcrt` feature) — matches what napi/cc-rs produced under
|
||||
cargo-xwin (rust msvc targets default to dynamic CRT without `+crt-static`).
|
||||
- **CRT: static `/MT` for the shipped addon.** The toolchain *default* is
|
||||
dynamic `/MD` (rules_cc's msvc branch default outside `dbg` without the
|
||||
`static_link_msvcrt` feature), matching what napi/cc-rs produced under
|
||||
cargo-xwin. But `//:natives-win32-x64-baseline` overrides to static CRT:
|
||||
`-Ctarget-feature=+crt-static` for rustc (crate BUILD select) plus the
|
||||
`static_link_msvcrt` cc feature (enabled for win32 in the `native_addon`
|
||||
transition, `bazel/defs.bzl`) so the C deps compile `/MT` in lock-step.
|
||||
Without this the `.node` imports `VCRUNTIME140.dll` from the Visual C++
|
||||
Redistributable, which is absent on a clean Windows install and makes the
|
||||
loader's dlopen fail with error 126 (issue #8439).
|
||||
- **SSE floor in the wrapper, not annotations**: `-msse4.1 -msse4.2` live in the
|
||||
clang-cl wrapper, which only ever targets win32-x64 (baseline = x86-64-v2 ⊇
|
||||
SSE4.2). This is the old build-native.ts CFLAGS hack, windows-only by
|
||||
@@ -95,7 +102,8 @@ exec hosts. Replaces cargo-xwin.
|
||||
defaults to the Debug config → `/MDd` → `msvcrtd.lib`, which the lean splat
|
||||
(like cargo-xwin's) does not carry; toolchain.cmake pins
|
||||
`CMAKE_TRY_COMPILE_CONFIGURATION=Release`, `CMAKE_POLICY_DEFAULT_CMP0091=NEW`
|
||||
and `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL` (/MD everywhere).
|
||||
and `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded` (static release `/MT`
|
||||
everywhere, matching the addon's static-CRT policy — issue #8439).
|
||||
Verified on darwin: scratch `project(C)` + `add_executable` configures with
|
||||
"Clang 20.1.7 with MSVC-like command-line" and links a valid PE32+ exe
|
||||
through vs_link_exe with the wrapper rc/mt/linker.
|
||||
@@ -103,8 +111,9 @@ exec hosts. Replaces cargo-xwin.
|
||||
## What to verify on can.internal (linux-x64)
|
||||
|
||||
1. `bazel build //:natives-win32-x64-baseline` end-to-end link; check the
|
||||
produced `pi_natives.win32-x64-baseline.node` imports (dumpbin/llvm-readobj:
|
||||
expect VCRUNTIME140/api-ms-win-crt-* → `/MD`, no static CRT).
|
||||
produced `pi_natives.win32-x64-baseline.node` imports (dumpbin/llvm-readobj):
|
||||
expect **no** `VCRUNTIME140.dll` and **no** `api-ms-win-crt-*` (static CRT);
|
||||
only core Windows system DLLs (kernel32, ntdll, advapi32, …) should remain.
|
||||
2. LLVM 20.1.7 Linux-X64 binaries are built on a newish Ubuntu: confirm the
|
||||
kata runner image's glibc is ≥ 2.35-ish and has `libtinfo6`/`libstdc++6`
|
||||
(usual LLVM release-binary runtime deps).
|
||||
|
||||
@@ -124,15 +124,20 @@ set(CMAKE_CXX_COMPILER "${CMAKE_CURRENT_LIST_DIR}/bin/clang-cl")
|
||||
set(CMAKE_LINKER "${CMAKE_CURRENT_LIST_DIR}/bin/lld-link")
|
||||
set(CMAKE_RC_COMPILER "${CMAKE_CURRENT_LIST_DIR}/bin/llvm-rc")
|
||||
set(CMAKE_MT "${CMAKE_CURRENT_LIST_DIR}/bin/llvm-mt")
|
||||
# The xwin splat carries release CRT import libs only (msvcrt.lib, no
|
||||
# msvcrtd.lib — same as cargo-xwin). try_compile defaults to the Debug
|
||||
# configuration, whose /MDd would demand the debug CRT; pin try_compile to
|
||||
# Release and the runtime library to dynamic release /MD for every config
|
||||
# (CMP0091 NEW makes CMAKE_MSVC_RUNTIME_LIBRARY authoritative even for
|
||||
# projects with ancient cmake_minimum_required, e.g. bundled opus).
|
||||
# The xwin splat carries release CRT import + static libs only (msvcrt.lib /
|
||||
# libcmt.lib, no debug msvcrtd.lib / libcmtd.lib — same as cargo-xwin).
|
||||
# try_compile defaults to the Debug configuration, whose debug CRT the splat
|
||||
# lacks; pin try_compile to Release. The shipped win32 addon statically links
|
||||
# the CRT (rustc +crt-static + the static_link_msvcrt cc feature; see
|
||||
# bazel/defs.bzl and crates/pi-natives/BUILD.bazel), so pin the runtime library
|
||||
# to the static release CRT /MT for every config as well — otherwise CMake's
|
||||
# authoritative CMAKE_MSVC_RUNTIME_LIBRARY (CMP0091 NEW) would emit /MD for the
|
||||
# bundled opus objects, which then import VCRUNTIME140.dll and conflict with the
|
||||
# static CRT the rest of the addon links (issue #8439). Keep this in lock-step
|
||||
# with the static_link_msvcrt feature: both must select the static CRT together.
|
||||
set(CMAKE_TRY_COMPILE_CONFIGURATION Release)
|
||||
set(CMAKE_POLICY_DEFAULT_CMP0091 NEW)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreadedDLL)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded)
|
||||
"""
|
||||
|
||||
_BUILD = """\
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/omptype": "catalog:",
|
||||
@@ -63,7 +63,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/omptype": "catalog:",
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -105,7 +105,6 @@
|
||||
"@opentelemetry/sdk-metrics": "catalog:",
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
"@opentelemetry/sdk-trace-node": "catalog:",
|
||||
"mupdf": "catalog:",
|
||||
"puppeteer-core": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -134,7 +133,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -178,7 +177,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -204,7 +203,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -212,7 +211,7 @@
|
||||
},
|
||||
"packages/omptype": {
|
||||
"name": "@oh-my-pi/omptype",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"devDependencies": {
|
||||
"@ark/attest": "0.56.3",
|
||||
"@ark/schema": "0.56.2",
|
||||
@@ -225,7 +224,7 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -239,7 +238,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -264,7 +263,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -300,7 +299,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
},
|
||||
@@ -310,7 +309,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -348,19 +347,19 @@
|
||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@napi-rs/cli": "3.7.2",
|
||||
"@oh-my-pi/hashline": "17.3.0",
|
||||
"@oh-my-pi/omp-stats": "17.3.0",
|
||||
"@oh-my-pi/omptype": "17.3.0",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.0",
|
||||
"@oh-my-pi/pi-ai": "17.3.0",
|
||||
"@oh-my-pi/pi-catalog": "17.3.0",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.0",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.0",
|
||||
"@oh-my-pi/pi-natives": "17.3.0",
|
||||
"@oh-my-pi/pi-tui": "17.3.0",
|
||||
"@oh-my-pi/pi-utils": "17.3.0",
|
||||
"@oh-my-pi/pi-wire": "17.3.0",
|
||||
"@oh-my-pi/snapcompact": "17.3.0",
|
||||
"@oh-my-pi/hashline": "17.3.4",
|
||||
"@oh-my-pi/omp-stats": "17.3.4",
|
||||
"@oh-my-pi/omptype": "17.3.4",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.4",
|
||||
"@oh-my-pi/pi-ai": "17.3.4",
|
||||
"@oh-my-pi/pi-catalog": "17.3.4",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.4",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.4",
|
||||
"@oh-my-pi/pi-natives": "17.3.4",
|
||||
"@oh-my-pi/pi-tui": "17.3.4",
|
||||
"@oh-my-pi/pi-utils": "17.3.4",
|
||||
"@oh-my-pi/pi-wire": "17.3.4",
|
||||
"@oh-my-pi/snapcompact": "17.3.4",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.220.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.9.0",
|
||||
@@ -386,7 +385,6 @@
|
||||
"ghostty-web": "^0.4.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lucide-react": "^1.24.0",
|
||||
"mupdf": "^1.28.0",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
"postcss": "^8.5.16",
|
||||
"prettier": "^3.9.5",
|
||||
@@ -455,23 +453,23 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg=="],
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg=="],
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ=="],
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q=="],
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g=="],
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg=="],
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="],
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="],
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.13.0", "", {}, "sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g=="],
|
||||
|
||||
@@ -1219,8 +1217,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mupdf": ["mupdf@1.28.0", "", {}, "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg=="],
|
||||
|
||||
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
@@ -1301,17 +1297,17 @@
|
||||
|
||||
"sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="],
|
||||
|
||||
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw=="],
|
||||
"sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ay//Bq4T3RHYhzdYk9O0ahg0UoEgbRMDGZvtpQi2D0oHOcXv0M5tP3OKlx4AvWNXYl0SL+rUObUFe5IIqROrfQ=="],
|
||||
|
||||
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw=="],
|
||||
"sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-1Jpkyv+Sg7wl+jCOlttcudVpVdlCNPXMf/RYT4+FU4/VmoMIx9MZbcSu2Nf8gtKq3mnYdkk7HtCK5AA3Dtuvwg=="],
|
||||
|
||||
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.4", "", { "os": "linux", "cpu": "x64" }, "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ=="],
|
||||
"sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-eedC/AMCL2cvwhtPnmRlLHKXuwBmD7o2r+Kb+I0krg4PlQejR9y6O1oKqdLko5vFaWe2A8/rQORKGXE+We3y4Q=="],
|
||||
|
||||
"sherpa-onnx-node": ["sherpa-onnx-node@1.13.2", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.13.2", "sherpa-onnx-darwin-x64": "^1.13.2", "sherpa-onnx-linux-arm64": "^1.13.2", "sherpa-onnx-linux-x64": "^1.13.2", "sherpa-onnx-win-ia32": "^1.13.2", "sherpa-onnx-win-x64": "^1.13.2" } }, "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA=="],
|
||||
|
||||
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ=="],
|
||||
"sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-aQKTmzsvNyCMOoAs+Clx8Zsp05JSX37hVpts/yiO3+mUTXnbBIMAL3YgG49apz6JN/l/HCJBaEczXj3odOgS8A=="],
|
||||
|
||||
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.4", "", { "os": "win32", "cpu": "x64" }, "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ=="],
|
||||
"sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-r1lKWEbFDquVjiij72nhNpmPBeqG7V06ZGx1uL2zWXFCYXmkpqawN5iHdkugVhb8QfYZ8x2tx88eYGHGMn88aA=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
@@ -1367,7 +1363,7 @@
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.0", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA=="],
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
|
||||
@@ -40,6 +40,13 @@ rust_shared_library(
|
||||
# cdylib at all; napi musl addons have always linked the dynamic CRT.
|
||||
"//bazel/triples:x86_64-unknown-linux-musl": ["-Ctarget-feature=-crt-static"],
|
||||
"//bazel/triples:aarch64-unknown-linux-musl": ["-Ctarget-feature=-crt-static"],
|
||||
# Statically link the MSVC CRT so the shipped .node does not import
|
||||
# VCRUNTIME140.dll from the Visual C++ Redistributable, which is absent
|
||||
# on a clean Windows install and makes the loader's dlopen fail with
|
||||
# "The specified module could not be found" (error 126). The C deps are
|
||||
# switched to /MT in lock-step via the static_link_msvcrt cc feature in
|
||||
# the native_addon transition (bazel/defs.bzl).
|
||||
"//bazel/triples:x86_64-pc-windows-msvc": ["-Ctarget-feature=+crt-static"],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
version = "17.1.5",
|
||||
|
||||
@@ -44,6 +44,7 @@ inferno.workspace = true
|
||||
napi.workspace = true
|
||||
napi-derive.workspace = true
|
||||
parking_lot.workspace = true
|
||||
pdf-inspector.workspace = true
|
||||
phf.workspace = true
|
||||
flume.workspace = true
|
||||
pi-ast.workspace = true
|
||||
|
||||
@@ -99,6 +99,43 @@ mod platform {
|
||||
static _NSConcreteStackBlock: *const c_void;
|
||||
}
|
||||
|
||||
// `SessionGetInfo` reports the caller's login-session attributes; consulted
|
||||
// to detect a GUI/graphic session before touching the GUI-only DeviceCheck
|
||||
// daemon.
|
||||
#[link(name = "Security", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn SessionGetInfo(session: u32, session_id: *mut u32, attributes: *mut u32) -> i32;
|
||||
}
|
||||
|
||||
/// `callerSecuritySession` — query the session hosting the current process.
|
||||
const CALLER_SECURITY_SESSION: u32 = u32::MAX;
|
||||
/// `sessionHasGraphicAccess` attribute bit from `Security/AuthSession.h`.
|
||||
const SESSION_HAS_GRAPHIC_ACCESS: u32 = 0x0010;
|
||||
|
||||
/// Whether the caller's security session has GUI/graphic access.
|
||||
///
|
||||
/// `DCDevice.isSupported` synchronously opens an XPC connection to the
|
||||
/// per-user `DeviceCheck` metadata daemon, which exists only in an
|
||||
/// interactive GUI login session. From a session without graphic access
|
||||
/// (SSH, a launchd `LaunchDaemon`, a CI runner, a service account, a
|
||||
/// sandbox) the connection setup hits `_xpc_api_misuse` and aborts the
|
||||
/// whole process with `SIGTRAP` before the completion handler can run — so
|
||||
/// there is no error to return, only a dead process. Gating on the
|
||||
/// documented session attribute keeps the call out of that trapping path.
|
||||
///
|
||||
/// Returns `false` when graphic access is absent *or* the session cannot be
|
||||
/// queried: degrading to "unsupported" merely drops the attestation header
|
||||
/// (exactly as on every non-macOS host), whereas optimistically assuming
|
||||
/// "supported" risks the process-killing trap.
|
||||
fn session_has_graphic_access() -> bool {
|
||||
let mut attributes: u32 = 0;
|
||||
// SAFETY: `SessionGetInfo` writes the caller session's attribute bits
|
||||
// through the out-pointer; the session-id slot is unused, so it is null.
|
||||
let status =
|
||||
unsafe { SessionGetInfo(CALLER_SECURITY_SESSION, ptr::null_mut(), &mut attributes) };
|
||||
status == 0 && attributes & SESSION_HAS_GRAPHIC_ACCESS != 0
|
||||
}
|
||||
|
||||
/// Outcome delivered once from the completion block to the waiting worker.
|
||||
enum Completion {
|
||||
Token(String),
|
||||
@@ -289,6 +326,10 @@ mod platform {
|
||||
error: None,
|
||||
latency_ms: 0.0,
|
||||
};
|
||||
if !session_has_graphic_access() {
|
||||
result.error = Some("DeviceCheck unavailable without a GUI login session".to_owned());
|
||||
return result;
|
||||
}
|
||||
// SAFETY: `c"DCDevice"` is a valid null-terminated class name.
|
||||
let class = unsafe { objc_getClass(c"DCDevice".as_ptr()) };
|
||||
if class.is_null() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! # Overview
|
||||
//! High-performance primitives for clipboard access, grep, file discovery,
|
||||
//! ANSI-aware text measurement, syntax highlighting, HTML-to-Markdown
|
||||
//! ANSI-aware text measurement, syntax highlighting, HTML/PDF-to-Markdown
|
||||
//! conversion, and terminal SIXEL encoding.
|
||||
//!
|
||||
//! # Example
|
||||
@@ -15,7 +15,7 @@
|
||||
//!
|
||||
//! # Architecture
|
||||
//! ```text
|
||||
//! JS (packages/natives) -> N-API -> Rust modules (clipboard/fd/glob/grep/html/highlight/sixel/text)
|
||||
//! JS (packages/natives) -> N-API -> Rust modules (clipboard/fd/glob/grep/html/pdf/highlight/sixel/text)
|
||||
//! ```
|
||||
|
||||
#![allow(clippy::trailing_empty_array, reason = "generated by napi macro")]
|
||||
@@ -41,6 +41,8 @@ pub mod html;
|
||||
pub mod iofs;
|
||||
pub mod keys;
|
||||
pub mod live;
|
||||
/// PDF inspection and Markdown conversion.
|
||||
pub mod pdf;
|
||||
pub mod sixel;
|
||||
pub mod snapcompact;
|
||||
pub use pi_ast::language;
|
||||
@@ -255,7 +257,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV17_3_0")]
|
||||
#[napi(js_name = "__piNativesV17_3_4")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! PDF inspection and Markdown conversion backed by `pdf-inspector`.
|
||||
|
||||
use napi::{Result, bindgen_prelude::Uint8Array};
|
||||
use napi_derive::napi;
|
||||
use pdf_inspector::{MarkdownOptions, PdfOptions, process_pdf_mem_with_options};
|
||||
|
||||
use crate::task;
|
||||
|
||||
/// Markdown and inspection metadata produced from a PDF document.
|
||||
#[napi(object)]
|
||||
pub struct PdfMarkdownResult {
|
||||
/// Extracted document content in Markdown format.
|
||||
pub markdown: String,
|
||||
/// Document title from PDF metadata, when present.
|
||||
pub title: Option<String>,
|
||||
/// Total number of pages in the document.
|
||||
pub page_count: u32,
|
||||
/// One-indexed page numbers whose content requires OCR.
|
||||
pub pages_needing_ocr: Vec<u32>,
|
||||
/// Whether the document contains text encoding problems.
|
||||
pub has_encoding_issues: bool,
|
||||
}
|
||||
|
||||
/// Convert an in-memory PDF to Markdown and return its inspection metadata.
|
||||
///
|
||||
/// Conversion copies the typed array before dispatch so JavaScript mutation
|
||||
/// cannot race the native worker.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error prefixed with `PDF conversion failed:` when the PDF cannot
|
||||
/// be parsed or converted.
|
||||
#[napi(js_name = "pdfToMarkdown")]
|
||||
pub fn pdf_to_markdown(input: Uint8Array) -> task::Promise<PdfMarkdownResult> {
|
||||
let input = input.to_vec();
|
||||
task::blocking("pdf.to_markdown", (), move |_| convert_pdf(&input))
|
||||
}
|
||||
|
||||
fn convert_pdf(input: &[u8]) -> Result<PdfMarkdownResult> {
|
||||
let options = PdfOptions::new()
|
||||
.markdown(MarkdownOptions { include_page_numbers: true, ..Default::default() });
|
||||
let converted = process_pdf_mem_with_options(input, options)
|
||||
.map_err(|error| napi::Error::from_reason(format!("PDF conversion failed: {error}")))?;
|
||||
let markdown = match converted.markdown {
|
||||
Some(markdown) => markdown,
|
||||
None if !converted.pages_needing_ocr.is_empty() => String::new(),
|
||||
None => {
|
||||
return Err(napi::Error::from_reason(
|
||||
"PDF conversion failed: converter returned no Markdown",
|
||||
));
|
||||
},
|
||||
};
|
||||
|
||||
Ok(PdfMarkdownResult {
|
||||
markdown,
|
||||
title: converted.title,
|
||||
page_count: converted.page_count,
|
||||
pages_needing_ocr: converted.pages_needing_ocr,
|
||||
has_encoding_issues: converted.has_encoding_issues,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pdf_fixture(page_contents: &[&str], title: Option<&str>) -> Vec<u8> {
|
||||
let font_id = 3 + page_contents.len() * 2;
|
||||
let info_id = title.map(|_| font_id + 1);
|
||||
let mut objects = Vec::with_capacity(font_id + usize::from(info_id.is_some()));
|
||||
objects.push("<< /Type /Catalog /Pages 2 0 R >>".to_string());
|
||||
|
||||
let kids = (0..page_contents.len())
|
||||
.map(|index| format!("{} 0 R", 3 + index * 2))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
objects.push(format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", page_contents.len()));
|
||||
|
||||
for (index, content) in page_contents.iter().enumerate() {
|
||||
let content_id = 4 + index * 2;
|
||||
objects.push(format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 \
|
||||
{font_id} 0 R >> >> /Contents {content_id} 0 R >>"
|
||||
));
|
||||
objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
|
||||
}
|
||||
|
||||
objects.push(
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
|
||||
.to_string(),
|
||||
);
|
||||
if let Some(title) = title {
|
||||
objects.push(format!("<< /Title ({title}) >>"));
|
||||
}
|
||||
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::with_capacity(objects.len());
|
||||
for (index, object) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes());
|
||||
}
|
||||
|
||||
let xref_offset = pdf.len();
|
||||
pdf.extend_from_slice(
|
||||
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||
);
|
||||
for offset in offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
let info = info_id.map_or_else(String::new, |id| format!(" /Info {id} 0 R"));
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R{info} >>\nstartxref\n{xref_offset}\n%%EOF\n",
|
||||
objects.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_text_title_and_page_markers() {
|
||||
let pdf = pdf_fixture(
|
||||
&[
|
||||
"BT /F1 12 Tf 72 720 Td (First page text) Tj 0 -18 Td (More first page text) Tj 0 -18 \
|
||||
Td (End first page) Tj ET",
|
||||
"BT /F1 12 Tf 72 720 Td (Second page text) Tj 0 -18 Td (More second page text) Tj 0 \
|
||||
-18 Td (End second page) Tj ET",
|
||||
],
|
||||
Some("Fixture Title"),
|
||||
);
|
||||
|
||||
let result = convert_pdf(&pdf).expect("fixture PDF should convert");
|
||||
|
||||
assert_eq!(result.title.as_deref(), Some("Fixture Title"));
|
||||
assert_eq!(result.page_count, 2);
|
||||
assert!(result.markdown.contains("First page text"), "{}", result.markdown);
|
||||
assert!(result.markdown.contains("Second page text"), "{}", result.markdown);
|
||||
assert!(result.markdown.contains("<!-- Page 1 -->"), "{}", result.markdown);
|
||||
assert!(result.markdown.contains("<!-- Page 2 -->"), "{}", result.markdown);
|
||||
assert!(!result.has_encoding_issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_empty_pages_as_needing_ocr() {
|
||||
let pdf = pdf_fixture(&[""], None);
|
||||
|
||||
let result = convert_pdf(&pdf).expect("empty-page PDF should still convert");
|
||||
|
||||
assert_eq!(result.page_count, 1);
|
||||
assert_eq!(result.pages_needing_ocr, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefixes_malformed_pdf_errors() {
|
||||
let error = convert_pdf(b"not a PDF")
|
||||
.err()
|
||||
.expect("malformed input should fail");
|
||||
|
||||
assert!(
|
||||
error.reason.starts_with("PDF conversion failed:"),
|
||||
"unexpected error: {}",
|
||||
error.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,10 @@ OAuth host chain: `KIMI_CODE_OAUTH_HOST` → `KIMI_OAUTH_HOST` → `https://auth
|
||||
| Variable | Default / behavior |
|
||||
| --------------------------- | --------------------------------------------------------------- |
|
||||
| `PI_AI_GEMINI_CLI_VERSION` | Overrides Gemini CLI user-agent version tag (`0.46.0` if unset) |
|
||||
| `PI_AI_ANTIGRAVITY_VERSION` | Overrides Antigravity hub user-agent version (`2.1.4` if unset) |
|
||||
| `PI_AI_ANTIGRAVITY_VERSION` | Overrides the auto-discovered Antigravity hub user-agent version; when unset and discovery fails, the fallback is `2.8.0` |
|
||||
| `PI_AI_ANTIGRAVITY_CL` | Overrides Antigravity hub user-agent build changelist (`963137146` if unset) |
|
||||
| `PI_AI_ANTIGRAVITY_OS` | Overrides Antigravity hub user-agent os_type (pinned `darwin` if unset) |
|
||||
| `PI_AI_ANTIGRAVITY_ARCH` | Overrides Antigravity hub user-agent arch (pinned `arm64` if unset) |
|
||||
|
||||
### GitLab Duo
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Contributor map for Rust workspace members under `crates/`. They are implementation details behind `@oh-my-pi/pi-natives` and its embedded shell; package consumers use JavaScript entrypoints, not these crate APIs.
|
||||
|
||||
The root `Cargo.toml` includes `crates/pi-*` and `crates/vendor/*` as workspace members. It also patches crates.io `brush-core` to the vendored copy.
|
||||
The root `Cargo.toml` lists every crate under `crates/` explicitly in `workspace.members` — add new crates there. It also patches crates.io `brush-core` to the vendored copy.
|
||||
|
||||
## First-party crates
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Notes:
|
||||
|
||||
This mirrors the old cargo `ci` profile. Because the profile lives **in the transition**, a bare `bazel build //:natives-<t>` is always release-grade regardless of `-c`, and every addon shares one cache entry per (platform, source) pair. The rule then symlinks the produced shared library to the loader's canonical `pi_natives.<platform>-<arch>[-<variant>].node` name, scoped under the rule name (`bazel-bin/natives-<t>/…`) so gnu/musl outputs with identical basenames cannot collide at the package level.
|
||||
|
||||
Per-target codegen that is not part of the transition lives in `crates/pi-natives/BUILD.bazel` `rustc_flags` selects: `-Ctarget-cpu=x86-64-v2` (baseline) / `x86-64-v3` (modern) via `//bazel/variants`, the napi link args (`-Wl,-undefined,dynamic_lookup` on macOS, `-Wl,-z,nodelete` on linux — `build.rs`/`napi_build::setup()` is deliberately not wired in), and `-Ctarget-feature=-crt-static` for musl.
|
||||
Per-target codegen that is not part of the transition lives in `crates/pi-natives/BUILD.bazel` `rustc_flags` selects: `-Ctarget-cpu=x86-64-v2` (baseline) / `x86-64-v3` (modern) via `//bazel/variants`, the napi link args (`-Wl,-undefined,dynamic_lookup` on macOS, `-Wl,-z,nodelete` on linux — `build.rs`/`napi_build::setup()` is deliberately not wired in), `-Ctarget-feature=-crt-static` for musl, and `-Ctarget-feature=+crt-static` for win32-x64 msvc (paired with the `static_link_msvcrt` cc feature enabled in the `native_addon` transition so the C deps compile `/MT` in lock-step — the shipped `.node` then imports no `VCRUNTIME140.dll` from the VC++ Redistributable).
|
||||
|
||||
### 3) Platforms and toolchains
|
||||
|
||||
@@ -73,7 +73,7 @@ Per-target codegen that is not part of the transition lives in `crates/pi-native
|
||||
| linux gnu (x64/arm64) | `@zig_sdk//libc_aware/toolchain:linux_*_gnu.2.17` (hermetic zig cc) | glibc **2.17** portability floor — same floor the previous cross builds used |
|
||||
| linux musl (x64/arm64) | `@zig_sdk//libc_aware/toolchain:linux_*_musl` | dynamic CRT (`-Ctarget-feature=-crt-static` in the crate BUILD) |
|
||||
| darwin (x64/arm64) | host Xcode toolchain | Apple frameworks aren't redistributable; darwin addons build on mac hosts only |
|
||||
| win32-x64 msvc | `//bazel/toolchains/msvc` (`@msvc_cc`): clang-cl + lld-link + xwin CRT/SDK | hermetic cross-link from linux-x64 CI pods and darwin dev hosts; see `bazel/toolchains/msvc/NOTES.md` |
|
||||
| win32-x64 msvc | `//bazel/toolchains/msvc` (`@msvc_cc`): clang-cl + lld-link + xwin CRT/SDK | hermetic cross-link from linux-x64 CI pods and darwin dev hosts; **static CRT** (`+crt-static` + `static_link_msvcrt`) so the addon needs no VC++ Redistributable; see `bazel/toolchains/msvc/NOTES.md` |
|
||||
|
||||
Rust toolchains are nightly (pinned in `MODULE.bazel`), with repo-local musl re-registrations in `//bazel/toolchains` carrying an explicit `@zig_sdk//libc:musl` constraint (rules_rust's generated gnu and musl toolchains otherwise share (os, cpu) constraints).
|
||||
|
||||
@@ -217,7 +217,7 @@ bazelisk build --nobuild //:natives-win32-x64-baseline
|
||||
| rstest macro: "Cargo.toml not found" in a vendored test | rstest verifies `Cargo.toml` exists in the manifest dir | `compile_data = ["Cargo.toml"]` on the `rust_test` (see `crates/vendor/uu-tail/BUILD.bazel`) |
|
||||
| vendored tests fail on bare `test_data/...` paths / symlink into srcs | tests assume cargo's cwd, incompatible with runfiles execution | `tags = ["manual"]`; run via `cargo nextest` when touching the fork; hermetic sibling test covers the contract |
|
||||
| blake3 msvc: `ml64.exe` not found | cc-rs resolves MASM from build-script PATH on non-windows hosts | `bin/ml64.exe → llvm-ml -m64` shim in `@msvc_cc`, prepended via the `blake3` annotation PATH |
|
||||
| audiopus_sys msvc: cmake demands VS generator / rc+mt tools; `try_compile` wants `msvcrtd.lib` | cross cmake on linux/mac hosts; Debug config → `/MDd` which the lean xwin splat lacks | `CMAKE_GENERATOR_x86_64_pc_windows_msvc=Ninja` + `@msvc_cc`'s `toolchain.cmake` (`CMAKE_TOOLCHAIN_FILE_x86_64_pc_windows_msvc`) pinning wrappers + Release try-compile + `/MD` |
|
||||
| audiopus_sys msvc: cmake demands VS generator / rc+mt tools; `try_compile` wants `msvcrtd.lib` | cross cmake on linux/mac hosts; Debug config → `/MDd` which the lean xwin splat lacks | `CMAKE_GENERATOR_x86_64_pc_windows_msvc=Ninja` + `@msvc_cc`'s `toolchain.cmake` (`CMAKE_TOOLCHAIN_FILE_x86_64_pc_windows_msvc`) pinning wrappers + Release try-compile + `/MT` (static CRT, matches the addon policy) |
|
||||
| win32 link oddities generally | — | read `bazel/toolchains/msvc/NOTES.md` first: wrapper self-location, `lld-link` flavor/driver-link behavior, `LIB`, `/MD` CRT choice, xwin splat caveats |
|
||||
| `rust_test(crate = ...)` "can't find crate" at macro expansion | rmeta-only pipelined deps break macro_rules re-export harness compiles | rust pipelined_compilation stays OFF (`.bazelrc` note) |
|
||||
| build script can't find cmake/ninja | `--incompatible_strict_action_env` — no host env leaks | explicit `PATH` in the crate annotation (`MODULE.bazel`), not host env |
|
||||
|
||||
@@ -246,8 +246,8 @@ Google Cloud Code Assist (CCA) transport wrapper accessing Gemini and Claude mod
|
||||
- **Function Calling Config Mode**: Defaults to `functionCallingConfig: { mode: "VALIDATED" }` for Antigravity in `buildRequest`. Claude models on Antigravity force `VALIDATED` mode even when context contains no declared tools (`isClaudeModel`). Single named tool choice (`options.toolChoice`) sets `mode: "ANY"` with `allowedFunctionNames: [...]`.
|
||||
- **Provider Protocol & Request Envelope**:
|
||||
- **Endpoints**: `google-gemini-cli` defaults to `https://cloudcode-pa.googleapis.com`. `google-antigravity` uses auto-failover across `https://daily-cloudcode-pa.googleapis.com` (primary) and `https://daily-cloudcode-pa.sandbox.googleapis.com` (sandbox), persisting `lastGoodEndpoint` in `AntigravityProviderSessionState`.
|
||||
- **Headers & User-Agent**: `google-gemini-cli` sends `getGeminiCliHeaders()` (`GeminiCLI/0.46.0/<modelId> (platform; arch; terminal)`). `google-antigravity` sends `getAntigravityUserAgent()` (`antigravity/hub/2.1.4 <os>/<arch>`). Reasoning Claude models on Antigravity send `anthropic-beta: interleaved-thinking-2025-05-14` (`needsClaudeThinkingBetaHeader`).
|
||||
- **System Instructions**: Antigravity tags system instructions with `role: "user"`. Claude and Gemini 3 models prepend `ANTIGRAVITY_SYSTEM_INSTRUCTION` ("You are Antigravity, a powerful agentic AI coding assistant...") via `shouldInjectAntigravitySystemInstruction`.
|
||||
- **Headers & User-Agent**: `google-gemini-cli` sends `getGeminiCliHeaders()` (`GeminiCLI/0.46.0/<modelId> (platform; arch; terminal)`). `google-antigravity` sends `getAntigravityUserAgent()` (`antigravity/hub/<version> (aidev_client; os_type=<os>; arch=<arch>; cl=<cl>)`); the backend gates newer models (e.g. gemini-3.7-flash) on the client version. Reasoning Claude models on Antigravity send `anthropic-beta: interleaved-thinking-2025-05-14` (`needsClaudeThinkingBetaHeader`).
|
||||
- **System Instructions**: Antigravity tags system instructions with `role: "user"`. No identity prompt is injected — the backend accepts arbitrary system instructions on all routes (verified against gemini-3.x and Claude wire ids).
|
||||
- **Request Envelope & Session State**: Antigravity wraps requests in `buildAntigravityRequestEnvelope`: `project` (projectId), `requestId` (`agent/<agentId>/<ts>/<trajectoryId>/<step>`), `userAgent` (`antigravity`), `requestType` (`agent`), and `labels` (`last_step_index`, `model_enum`, `trajectory_id`, `used_claude`, `used_claude_conservative`, `last_execution_id`). State maintains monotonic `stepIndex`, persistent `agentId`, `trajectoryId`, and signed-decimal `sessionId` (`deriveAntigravitySessionId`).
|
||||
- **Wire Profiles**: `getAntigravityModelWireProfile` (`packages/catalog/src/wire/gemini-headers.ts`) maps wire IDs to `maxOutputTokens` and `model_enum`. Claude wire IDs cap `maxOutputTokens` at `64000` (backend rejects >64000 with 400).
|
||||
- **Thinking Configuration & Wire Suppression**: Gemini 2.x models send `thinkingConfig.thinkingBudget`, while Gemini 3 models send `thinkingConfig.thinkingLevel`. When reasoning is disabled for models with `thinking.suppressWhenOff`, `buildRequest` emits explicit wire suppression (`includeThoughts: false` with level/budget). Omitting `thinkingConfig` causes CCA to re-apply server defaults and silently bill thinking tokens.
|
||||
@@ -892,7 +892,7 @@ The Google Antigravity provider (`google-antigravity`) routes requests to Google
|
||||
|
||||
### Special casings
|
||||
- **Validated Function Calling Default**: Default tool selection mode in `buildRequest` (`packages/ai/src/providers/google-gemini-cli.ts`) is `VALIDATED` (`functionCallingConfig: { mode: "VALIDATED" }`). Claude models on Antigravity always force `VALIDATED` tool mode even when no tools are declared (`packages/ai/src/providers/google-gemini-cli.ts`).
|
||||
- **System Instruction & Request Envelope**: `shouldInjectAntigravitySystemInstruction` in `packages/ai/src/providers/google-gemini-cli.ts` prepends `ANTIGRAVITY_SYSTEM_INSTRUCTION` with `role: "user"` for Claude and Gemini 3 models. `buildAntigravityRequestEnvelope` injects structured `requestId` (`agent/<id>/<ts>/<trajectoryId>/<step>`), `userAgent: "antigravity"`, `requestType: "agent"`, `sessionId`, and `labels` (`model_enum`, `trajectory_id`, `last_step_index`, `last_execution_id`, `used_claude*`) using `getAntigravityModelWireProfile`.
|
||||
- **System Instruction & Request Envelope**: Antigravity tags `systemInstruction` with `role: "user"` and sends the caller's prompts unmodified. `buildAntigravityRequestEnvelope` injects structured `requestId` (`agent/<id>/<ts>/<trajectoryId>/<step>`), `userAgent: "antigravity"`, `requestType: "agent"`, `sessionId`, and `labels` (`model_enum`, `trajectory_id`, `last_step_index`, `last_execution_id`, `used_claude*`) using `getAntigravityModelWireProfile`.
|
||||
- **Endpoint Auto-Failover**: Operates across `ANTIGRAVITY_DAILY_ENDPOINT` (`https://daily-cloudcode-pa.googleapis.com`) and `ANTIGRAVITY_SANDBOX_ENDPOINT` (`https://daily-cloudcode-pa.sandbox.googleapis.com`) with state-tracked fallback in `getAntigravityProviderSessionState` (`packages/ai/src/providers/google-gemini-cli.ts`).
|
||||
|
||||
### Auth & usage
|
||||
|
||||
+17
-20
@@ -66,7 +66,7 @@ needs to know whether the user has scrolled away from the tail.
|
||||
- A component tree that reports **no seam** gets shell semantics: whatever
|
||||
scrolls off is final. Shrinking such a frame into its committed prefix
|
||||
re-anchors the window and leaves the stale copy in history (§3).
|
||||
- Inside ED3-unsafe multiplexers, a width change terminates the physical-row
|
||||
- Inside terminal multiplexers, a width change terminates the physical-row
|
||||
coordinate epoch. The renderer captures an opaque
|
||||
`NativeScrollbackWidthEpoch` marker from the last emitted source state before
|
||||
`SIGWINCH`, then resolves that same logical boundary after the settled-width
|
||||
@@ -77,11 +77,9 @@ needs to know whether the user has scrolled away from the tail.
|
||||
and no old viewport row is recommitted. Components without the source
|
||||
contract retain the conservative physical-row fallback. Visible overlays
|
||||
freeze the seam and pinned live regions clip advancement at their final
|
||||
boundary. Height-only resizes retain the existing ledger.
|
||||
- Direct HerdR panes are not in that category. HerdR's Ghostty core implements
|
||||
ED3, so OMP clears and replays its source-owned transcript after settlement,
|
||||
matching direct terminals. Repainting host-reflowed rows in place would
|
||||
harden soft wraps and leave residual rows after a later width reversal.
|
||||
boundary. Height-only resizes retain the existing ledger. Direct HerdR panes
|
||||
use this path because clearing and replaying scrollback flickers in its
|
||||
host-owned pane.
|
||||
|
||||
---
|
||||
|
||||
@@ -188,20 +186,19 @@ contract, not a terminal-specific optimization.
|
||||
deliberate exception: it clears and replays the complete current frame.
|
||||
3. **Commits are exactly the chunk.** Any byte shape that scrolls the screen
|
||||
must scroll only rows accounted for by the commit advance.
|
||||
4. **An ED3-unsafe multiplexer width resize NEVER advances history.** The old
|
||||
committed physical-row coordinate is opaque after reflow. The resize leaves
|
||||
the host-reflowed viewport in place and establishes a complete-frame
|
||||
baseline independent of the native commit count. Subsequent growth writes
|
||||
the exact current-width rows newly crossing the seam—not blank scroll
|
||||
commands—then repaints the bounded viewport; only that slice advances
|
||||
commits. Visible overlays advance neither the baseline nor the seam ledger;
|
||||
overlay exit backfills the exact hidden slice. Pinned live regions advance
|
||||
only through their final boundary; finalization releases the deferred
|
||||
mutable slice. During a height shrink, only occupied old-frame rows actually
|
||||
moved into history by the host are excluded from the append-owned seam;
|
||||
empty viewport rows do not consume content-driven movement. Height-only
|
||||
resizes do not terminate the epoch. Direct HerdR uses ED3 source replay
|
||||
instead.
|
||||
4. **A multiplexer width resize NEVER advances history.** The old committed
|
||||
physical-row coordinate is opaque after reflow. The resize leaves the
|
||||
host-reflowed viewport in place and establishes a complete-frame baseline
|
||||
independent of the native commit count. Subsequent growth writes the exact
|
||||
current-width rows newly crossing the seam—not blank scroll commands—then
|
||||
repaints the bounded viewport; only that slice advances commits. Visible
|
||||
overlays advance neither the baseline nor the seam ledger; overlay exit
|
||||
backfills the exact hidden slice. Pinned live regions advance only through
|
||||
their final boundary; finalization releases the deferred mutable slice.
|
||||
During a height shrink, only occupied old-frame rows actually moved into
|
||||
history by the host are excluded from the append-owned seam; empty viewport
|
||||
rows do not consume content-driven movement. Height-only resizes do not
|
||||
terminate the epoch.
|
||||
5. **NEVER probe the viewport position or fork on platform in the update
|
||||
path.** win32 behaves like POSIX. The probe APIs are gone; do not
|
||||
reintroduce them.
|
||||
|
||||
@@ -162,9 +162,10 @@ Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.
|
||||
|
||||
Effects:
|
||||
|
||||
- A resize is an explicit user gesture: on direct terminals and direct HerdR panes the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame. HerdR's Ghostty core supports ED3, and replay avoids converting its reflowed soft wraps into permanent row boundaries.
|
||||
- Inside ED3-unsafe terminal multiplexers, height-only resize retains the append ledger and repaints the visible window in place after the settle debounce (issue #2088). A width change instead terminates the physical-row epoch: old committed coordinates become opaque, pane history remains immutable at its authored wrap, and the settled render leaves the host-reflowed viewport in place. The current frame length becomes a baseline independent of the native committed-row count. Subsequent growth writes the exact current-width rows newly crossing the scrollback seam, follows them with a bounded viewport repaint, and advances commits by exactly that emitted slice. Ordinary and forced renders arriving during the debounce are folded into the settled repaint without postponing source updates.
|
||||
- Nested tmux, screen, Zellij, or cmux sessions inside HerdR remain on the ED3-unsafe path because the inner multiplexer owns its history.
|
||||
- Direct HerdR panes follow the in-place multiplexer path: their host owns the
|
||||
pane, and destructive `ED3` transcript replay produces visible flashes.
|
||||
- Inside terminal multiplexers, height-only resize retains the append ledger and repaints the visible window in place after the settle debounce (issue #2088). A width change instead terminates the physical-row epoch: old committed coordinates become opaque, pane history remains immutable at its authored wrap, and the settled render establishes a complete-frame baseline. Subsequent growth writes only current-width rows newly crossing the scrollback seam before repainting the bounded viewport.
|
||||
- Nested tmux, screen, Zellij, or cmux sessions inside HerdR use the same path.
|
||||
- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering ED3-unsafe multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer.
|
||||
- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.
|
||||
|
||||
|
||||
+45
-49
@@ -121,41 +121,41 @@
|
||||
url = "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz";
|
||||
hash = "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==";
|
||||
};
|
||||
"@biomejs/biome@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz";
|
||||
hash = "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==";
|
||||
"@biomejs/biome@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.8.tgz";
|
||||
hash = "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw==";
|
||||
};
|
||||
"@biomejs/cli-darwin-arm64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz";
|
||||
hash = "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==";
|
||||
"@biomejs/cli-darwin-arm64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.8.tgz";
|
||||
hash = "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA==";
|
||||
};
|
||||
"@biomejs/cli-darwin-x64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz";
|
||||
hash = "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==";
|
||||
"@biomejs/cli-darwin-x64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.8.tgz";
|
||||
hash = "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w==";
|
||||
};
|
||||
"@biomejs/cli-linux-arm64-musl@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz";
|
||||
hash = "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==";
|
||||
"@biomejs/cli-linux-arm64-musl@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.8.tgz";
|
||||
hash = "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw==";
|
||||
};
|
||||
"@biomejs/cli-linux-arm64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz";
|
||||
hash = "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==";
|
||||
"@biomejs/cli-linux-arm64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.8.tgz";
|
||||
hash = "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ==";
|
||||
};
|
||||
"@biomejs/cli-linux-x64-musl@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz";
|
||||
hash = "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==";
|
||||
"@biomejs/cli-linux-x64-musl@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.8.tgz";
|
||||
hash = "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w==";
|
||||
};
|
||||
"@biomejs/cli-linux-x64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz";
|
||||
hash = "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==";
|
||||
"@biomejs/cli-linux-x64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.8.tgz";
|
||||
hash = "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ==";
|
||||
};
|
||||
"@biomejs/cli-win32-arm64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz";
|
||||
hash = "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==";
|
||||
"@biomejs/cli-win32-arm64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.8.tgz";
|
||||
hash = "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA==";
|
||||
};
|
||||
"@biomejs/cli-win32-x64@2.5.7" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz";
|
||||
hash = "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==";
|
||||
"@biomejs/cli-win32-x64@2.5.8" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.8.tgz";
|
||||
hash = "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA==";
|
||||
};
|
||||
"@bufbuild/protobuf@2.13.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.13.0.tgz";
|
||||
@@ -1738,10 +1738,6 @@
|
||||
url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz";
|
||||
hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==";
|
||||
};
|
||||
"mupdf@1.28.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/mupdf/-/mupdf-1.28.0.tgz";
|
||||
hash = "sha512-ACUnbpECaQ5JLq04pwd89lS+0IGMest5qL5tb08g9TAR7bDtfqflHEkb2Xm3o4rvC/szguLiV+WEbW9kstj8Sg==";
|
||||
};
|
||||
"mute-stream@3.0.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz";
|
||||
hash = "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==";
|
||||
@@ -1927,29 +1923,29 @@
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.13.3.tgz";
|
||||
hash = "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g==";
|
||||
};
|
||||
"sherpa-onnx-darwin-x64@1.13.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.13.4.tgz";
|
||||
hash = "sha512-6RGeis9K9gV/UQWOgd6Rf3iqXr2/YsBQswxHaCR4hrYkHfEIpHMfFmRWLt6nJJCOWgYW2xFxEd9yzjrafAV/Pw==";
|
||||
"sherpa-onnx-darwin-x64@1.13.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.13.5.tgz";
|
||||
hash = "sha512-Ay//Bq4T3RHYhzdYk9O0ahg0UoEgbRMDGZvtpQi2D0oHOcXv0M5tP3OKlx4AvWNXYl0SL+rUObUFe5IIqROrfQ==";
|
||||
};
|
||||
"sherpa-onnx-linux-arm64@1.13.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.13.4.tgz";
|
||||
hash = "sha512-RMjMRqT82BgTXypNNGmLe6ZFYhc3WEvnAGl3DdkK7qB/kuXwkL3iHhV31wAecbnWPsnEpUoD+8cFovWSBzsCuw==";
|
||||
"sherpa-onnx-linux-arm64@1.13.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.13.5.tgz";
|
||||
hash = "sha512-1Jpkyv+Sg7wl+jCOlttcudVpVdlCNPXMf/RYT4+FU4/VmoMIx9MZbcSu2Nf8gtKq3mnYdkk7HtCK5AA3Dtuvwg==";
|
||||
};
|
||||
"sherpa-onnx-linux-x64@1.13.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.13.4.tgz";
|
||||
hash = "sha512-WZh5NCkGPFHHpYSd78iN4OnmxQeSTGyt9uZskH+im/NFHQ7elQ7B0sLzCMeRpvJxiIKvd9C6WxIJ4hYaxClfsQ==";
|
||||
"sherpa-onnx-linux-x64@1.13.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.13.5.tgz";
|
||||
hash = "sha512-eedC/AMCL2cvwhtPnmRlLHKXuwBmD7o2r+Kb+I0krg4PlQejR9y6O1oKqdLko5vFaWe2A8/rQORKGXE+We3y4Q==";
|
||||
};
|
||||
"sherpa-onnx-node@1.13.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.13.2.tgz";
|
||||
hash = "sha512-uIH6SA5Or4pb8HlCYWB3K54XkMtzdef4/tkw1amtIf8GB1tt6hQLpur9p2jSFNfTYRyzZ8XrXofxefXQ0A7EUA==";
|
||||
};
|
||||
"sherpa-onnx-win-ia32@1.13.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.13.4.tgz";
|
||||
hash = "sha512-/JbPjldrfNv+t+uIS3MlkuhfIf5l3FHUGkRC2oRXgjRqOaVmEyP3vLlQ7dTa4J7raG5oB8c3GoPjuSWSqT9GOQ==";
|
||||
"sherpa-onnx-win-ia32@1.13.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.13.5.tgz";
|
||||
hash = "sha512-aQKTmzsvNyCMOoAs+Clx8Zsp05JSX37hVpts/yiO3+mUTXnbBIMAL3YgG49apz6JN/l/HCJBaEczXj3odOgS8A==";
|
||||
};
|
||||
"sherpa-onnx-win-x64@1.13.4" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.13.4.tgz";
|
||||
hash = "sha512-R0PWby1VxC14TDZPq7GcfSyXSY6SAFO8Y4JwdCdqouFmeXkZ1L7Is9m98C9KxQ0dN7ZtDzhAmE/43FUs/elXRQ==";
|
||||
"sherpa-onnx-win-x64@1.13.5" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.13.5.tgz";
|
||||
hash = "sha512-r1lKWEbFDquVjiij72nhNpmPBeqG7V06ZGx1uL2zWXFCYXmkpqawN5iHdkugVhb8QfYZ8x2tx88eYGHGMn88aA==";
|
||||
};
|
||||
"sherpa-onnx@1.13.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/sherpa-onnx/-/sherpa-onnx-1.13.2.tgz";
|
||||
@@ -2087,9 +2083,9 @@
|
||||
url = "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz";
|
||||
hash = "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==";
|
||||
};
|
||||
"update-browserslist-db@1.3.0" = fetchurl {
|
||||
url = "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz";
|
||||
hash = "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==";
|
||||
"update-browserslist-db@1.3.1" = fetchurl {
|
||||
url = "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz";
|
||||
hash = "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==";
|
||||
};
|
||||
"util-deprecate@1.0.2" = fetchurl {
|
||||
url = "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz";
|
||||
|
||||
+13
-16
@@ -23,19 +23,19 @@
|
||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@napi-rs/cli": "3.7.2",
|
||||
"@oh-my-pi/hashline": "17.3.0",
|
||||
"@oh-my-pi/omp-stats": "17.3.0",
|
||||
"@oh-my-pi/omptype": "17.3.0",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.0",
|
||||
"@oh-my-pi/pi-ai": "17.3.0",
|
||||
"@oh-my-pi/pi-catalog": "17.3.0",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.0",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.0",
|
||||
"@oh-my-pi/pi-natives": "17.3.0",
|
||||
"@oh-my-pi/pi-tui": "17.3.0",
|
||||
"@oh-my-pi/pi-utils": "17.3.0",
|
||||
"@oh-my-pi/pi-wire": "17.3.0",
|
||||
"@oh-my-pi/snapcompact": "17.3.0",
|
||||
"@oh-my-pi/hashline": "17.3.4",
|
||||
"@oh-my-pi/omp-stats": "17.3.4",
|
||||
"@oh-my-pi/omptype": "17.3.4",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.4",
|
||||
"@oh-my-pi/pi-ai": "17.3.4",
|
||||
"@oh-my-pi/pi-catalog": "17.3.4",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.4",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.4",
|
||||
"@oh-my-pi/pi-natives": "17.3.4",
|
||||
"@oh-my-pi/pi-tui": "17.3.4",
|
||||
"@oh-my-pi/pi-utils": "17.3.4",
|
||||
"@oh-my-pi/pi-wire": "17.3.4",
|
||||
"@oh-my-pi/snapcompact": "17.3.4",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.220.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.9.0",
|
||||
@@ -61,7 +61,6 @@
|
||||
"ghostty-web": "^0.4.0",
|
||||
"lint-staged": "^17.0.8",
|
||||
"lucide-react": "^1.24.0",
|
||||
"mupdf": "^1.28.0",
|
||||
"onnxruntime-node": "1.26.0",
|
||||
"postcss": "^8.5.16",
|
||||
"prettier": "^3.9.5",
|
||||
@@ -170,8 +169,6 @@
|
||||
"gen:nix": "bun scripts/gen-nix-bun.ts",
|
||||
"gen:tool-views": "bun --cwd=packages/collab-web run gen:tool-views",
|
||||
"gen:bundle": "bun --cwd=packages/coding-agent run gen:bundle",
|
||||
"gen:mupdf": "bun --cwd=packages/coding-agent run gen:mupdf",
|
||||
"gen:mupdf:reset": "bun --cwd=packages/coding-agent run gen:mupdf:reset",
|
||||
"gen:native": "bun --cwd=packages/natives run gen:native",
|
||||
"gen:native:reset": "bun --cwd=packages/natives run gen:native:reset",
|
||||
"check-spoofed-versions": "bun scripts/check-spoofed-versions.ts"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Codex-compatible V2 remote compaction with an explicit `v2Endpoint` by sending the required feature-negotiation header ([#8524](https://github.com/can1357/oh-my-pi/issues/8524)).
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -423,6 +423,7 @@ function buildCompactionV2Headers(
|
||||
}
|
||||
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
||||
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
||||
headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
|
||||
if (model.useResponsesLite) {
|
||||
headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
|
||||
}
|
||||
|
||||
@@ -2216,7 +2216,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
let steerReady = false;
|
||||
let drained = false;
|
||||
let observedAbort = false;
|
||||
let resolvedByTimeout = false;
|
||||
const toolRelease = Promise.withResolvers<void>();
|
||||
|
||||
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
|
||||
name: "wait",
|
||||
@@ -2226,24 +2226,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
interruptible: params => params.op === "wait",
|
||||
async execute(_toolCallId, _params, signal) {
|
||||
steerReady = true;
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
resolvedByTimeout = true;
|
||||
resolve();
|
||||
}, 300);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
await promise;
|
||||
if (!signal?.aborted) await toolRelease.promise;
|
||||
observedAbort = signal?.aborted === true;
|
||||
return { content: [{ type: "text", text: "waited" }], details: {} };
|
||||
},
|
||||
@@ -2260,7 +2243,11 @@ describe("agentLoop with AgentMessage", () => {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
interruptMode: "immediate",
|
||||
hasSteeringMessages: () => steerReady && !drained,
|
||||
hasSteeringMessages: () => {
|
||||
const queued = steerReady && !drained;
|
||||
if (queued) toolRelease.resolve();
|
||||
return queued;
|
||||
},
|
||||
getSteeringMessages: async () => {
|
||||
if (steerReady && !drained) {
|
||||
drained = true;
|
||||
@@ -2276,7 +2263,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
}
|
||||
|
||||
expect(observedAbort).toBe(false);
|
||||
expect(resolvedByTimeout).toBe(true);
|
||||
expect(steerReady).toBe(true);
|
||||
expect(drained).toBe(true);
|
||||
expect(
|
||||
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
|
||||
@@ -3203,7 +3190,12 @@ describe("agentLoop event-driven steering watch", () => {
|
||||
// drain
|
||||
}
|
||||
})();
|
||||
const completed = await Promise.race([drain.then(() => true), Bun.sleep(1000).then(() => false)]);
|
||||
// This is the behavior under test, so retain a deadline; cancel its timer
|
||||
// when teardown succeeds instead of leaving a losing sleep alive.
|
||||
const timeout = Promise.withResolvers<boolean>();
|
||||
const timeoutId = setTimeout(() => timeout.resolve(false), 1000);
|
||||
const completed = await Promise.race([drain.then(() => true), timeout.promise]);
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
expect(completed).toBe(true);
|
||||
expect(executed).toEqual(["only"]);
|
||||
|
||||
@@ -36,17 +36,27 @@ describe("agentPauseGate", () => {
|
||||
const context: AgentContext = { systemPrompt: ["Test"], messages: [], tools: [] };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
|
||||
const parked = Promise.withResolvers<void>();
|
||||
const originalWait = agentPauseGate.waitUntilResumed;
|
||||
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
|
||||
parked.resolve();
|
||||
return originalWait.call(agentPauseGate, signal);
|
||||
};
|
||||
expect(agentPauseGate.pause()).toBe(true);
|
||||
expect(agentPauseGate.pause()).toBe(false); // already engaged
|
||||
|
||||
const result = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream).result();
|
||||
await Bun.sleep(20);
|
||||
await parked.promise;
|
||||
expect(mock.calls.length).toBe(0); // parked before the first provider call
|
||||
|
||||
expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0);
|
||||
const messages = await result;
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(messages[messages.length - 1].role).toBe("assistant");
|
||||
try {
|
||||
expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0);
|
||||
const messages = await result;
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(messages[messages.length - 1].role).toBe("assistant");
|
||||
} finally {
|
||||
agentPauseGate.waitUntilResumed = originalWait;
|
||||
}
|
||||
});
|
||||
|
||||
it("holds tool execution at the tool boundary when paused mid-turn", async () => {
|
||||
@@ -96,6 +106,12 @@ describe("agentPauseGate", () => {
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
const abortController = new AbortController();
|
||||
|
||||
const parked = Promise.withResolvers<void>();
|
||||
const originalWait = agentPauseGate.waitUntilResumed;
|
||||
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
|
||||
parked.resolve();
|
||||
return originalWait.call(agentPauseGate, signal);
|
||||
};
|
||||
agentPauseGate.pause();
|
||||
const result = agentLoop(
|
||||
[createUserMessage("hi")],
|
||||
@@ -104,19 +120,23 @@ describe("agentPauseGate", () => {
|
||||
abortController.signal,
|
||||
mock.stream,
|
||||
).result();
|
||||
await Bun.sleep(20);
|
||||
await parked.promise;
|
||||
abortController.abort("user interrupt");
|
||||
|
||||
// The run must terminate as aborted promptly (not stay parked until
|
||||
// resume). The provider request itself carries the aborted signal, so
|
||||
// whether the transport is entered at all is an implementation detail.
|
||||
const messages = await result;
|
||||
const last = messages[messages.length - 1];
|
||||
expect(last.role).toBe("assistant");
|
||||
if (last.role === "assistant") {
|
||||
expect(last.stopReason).toBe("aborted");
|
||||
try {
|
||||
const messages = await result;
|
||||
const last = messages[messages.length - 1];
|
||||
expect(last.role).toBe("assistant");
|
||||
if (last.role === "assistant") {
|
||||
expect(last.stopReason).toBe("aborted");
|
||||
}
|
||||
expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process
|
||||
} finally {
|
||||
agentPauseGate.waitUntilResumed = originalWait;
|
||||
}
|
||||
expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process
|
||||
});
|
||||
|
||||
it("re-parks a waiter when the gate is re-engaged in the same tick as resume", async () => {
|
||||
@@ -128,7 +148,7 @@ describe("agentPauseGate", () => {
|
||||
|
||||
agentPauseGate.resume();
|
||||
agentPauseGate.pause(); // re-engage before the waiter's microtask runs
|
||||
await Bun.sleep(10);
|
||||
await Promise.resolve();
|
||||
expect(released).toBe(false);
|
||||
|
||||
agentPauseGate.resume();
|
||||
|
||||
@@ -745,6 +745,7 @@ describe("requestCompactionV2Streaming", () => {
|
||||
let sessionHeader: string | undefined;
|
||||
let clientRequestHeader: string | undefined;
|
||||
let legacySessionHeader: string | undefined;
|
||||
let betaFeaturesHeader: string | undefined;
|
||||
const fetchMock: FetchImpl = async (input, init) => {
|
||||
expect(String(input)).toBe("https://compact.example/v1/responses");
|
||||
if (!init?.headers || init.headers instanceof Headers || Array.isArray(init.headers)) {
|
||||
@@ -753,9 +754,11 @@ describe("requestCompactionV2Streaming", () => {
|
||||
const rawSessionHeader = init.headers.session_id;
|
||||
const rawClientRequestHeader = init.headers["x-client-request-id"];
|
||||
const rawLegacySessionHeader = init.headers["session-id"];
|
||||
const rawBetaFeaturesHeader = init.headers["x-codex-beta-features"];
|
||||
sessionHeader = typeof rawSessionHeader === "string" ? rawSessionHeader : undefined;
|
||||
clientRequestHeader = typeof rawClientRequestHeader === "string" ? rawClientRequestHeader : undefined;
|
||||
legacySessionHeader = typeof rawLegacySessionHeader === "string" ? rawLegacySessionHeader : undefined;
|
||||
betaFeaturesHeader = typeof rawBetaFeaturesHeader === "string" ? rawBetaFeaturesHeader : undefined;
|
||||
requestBody = JSON.parse(String(init.body)) as {
|
||||
model: string;
|
||||
input: Array<Record<string, unknown>>;
|
||||
@@ -789,6 +792,7 @@ describe("requestCompactionV2Streaming", () => {
|
||||
expect(sessionHeader).toBe("session-1");
|
||||
expect(clientRequestHeader).toBe("session-1");
|
||||
expect(legacySessionHeader).toBeUndefined();
|
||||
expect(betaFeaturesHeader).toBeUndefined();
|
||||
expect(requestBody?.model).toBe("gpt-5-compact");
|
||||
expect(requestBody?.prompt_cache_key).toBe("cache-1");
|
||||
expect(requestBody?.input[requestBody.input.length - 1]).toEqual({ type: "compaction_trigger" });
|
||||
@@ -798,6 +802,52 @@ describe("requestCompactionV2Streaming", () => {
|
||||
expect(result.usage?.reasoningOutputTokens).toBe(1);
|
||||
});
|
||||
|
||||
test("negotiates Codex V2 compaction for an explicit Responses endpoint", async () => {
|
||||
const model = buildModel({
|
||||
id: "gpt-5",
|
||||
name: "GPT-5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
remoteCompaction: {
|
||||
enabled: true,
|
||||
api: "openai-codex-responses",
|
||||
v2StreamingEnabled: true,
|
||||
v2Endpoint: "https://compact.example/v1/responses",
|
||||
},
|
||||
});
|
||||
const request = buildCompactionV2Request(
|
||||
model,
|
||||
[{ type: "message", role: "user", content: [{ type: "input_text", text: "real user" }] }],
|
||||
"instructions",
|
||||
);
|
||||
let betaFeaturesHeader: string | undefined;
|
||||
const fetchMock: FetchImpl = async (_input, init) => {
|
||||
if (!init?.headers || init.headers instanceof Headers || Array.isArray(init.headers)) {
|
||||
throw new Error("Expected V2 compaction to send headers as a plain object");
|
||||
}
|
||||
const rawBetaFeaturesHeader = init.headers["x-codex-beta-features"];
|
||||
betaFeaturesHeader = typeof rawBetaFeaturesHeader === "string" ? rawBetaFeaturesHeader : undefined;
|
||||
return sseResponse([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "compaction", encrypted_content: "enc" },
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } },
|
||||
]);
|
||||
};
|
||||
|
||||
await requestCompactionV2Streaming(model, "test-key", request, undefined, { fetch: fetchMock });
|
||||
|
||||
expect(betaFeaturesHeader).toBe("remote_compaction_v2");
|
||||
});
|
||||
|
||||
test("retries transient V2 stream failures with a fresh request attempt", async () => {
|
||||
const model = makeOpenAiModel({
|
||||
remoteCompaction: {
|
||||
|
||||
@@ -2,6 +2,31 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `omp usage invalidate` to discard stale OAuth and API-key usage snapshots, then force a cache-bypassing, per-provider serialized refresh with a broker request budget sized for the full unfiltered account batch, so upgraded subscriptions do not silently retain pre-change quota data.
|
||||
- Fixed quota reporting and Cookie capture guidance for China (Beijing) Alibaba Token Plan credentials ([#8509](https://github.com/can1357/oh-my-pi/issues/8509)).
|
||||
|
||||
## [17.3.3] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and duplicate Antigravity endpoint streams while surfacing the missing final output for session-level recovery.
|
||||
|
||||
## [17.3.2] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dropped unsigned thinking blocks from Antigravity Claude requests instead of sending them without a signature, preventing HTTP 400 responses when resuming sessions or switching models.
|
||||
- Classified Antigravity HTTP 429 responses from structured `google.rpc.ErrorInfo` reasons (`QUOTA_EXHAUSTED`, `RATE_LIMIT_EXCEEDED`, and `INSUFFICIENT_G1_CREDITS_BALANCE`), using retry delays of five minutes or longer to distinguish rotatable quota windows from transient throttling instead of relying only on message regexes.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the Antigravity identity-prompt injection (`ANTIGRAVITY_SYSTEM_INSTRUCTION` and `shouldInjectAntigravitySystemInstruction`): Cloud Code Assist accepts arbitrary system instructions on gemini-3.x and Claude routes (verified live), and the injected stub never matched the real client's system prompt anyway. User system prompts are now sent unmodified (still tagged `role: "user"`).
|
||||
- Fixed Antigravity `auto` mode not failing over to the sandbox endpoint when the daily endpoint returned a thinking-only `STOP`, which caused Advisor turns to be falsely recorded as empty-response failures ([#8480](https://github.com/can1357/oh-my-pi/issues/8480)).
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -249,12 +249,23 @@ export class AuthBrokerClient {
|
||||
}
|
||||
}
|
||||
|
||||
fetchUsage(signal?: AbortSignal): Promise<UsageResponse> {
|
||||
// Validates the envelope (`generatedAt`, `reports[].provider`, `limits`,
|
||||
// `metadata`) but leaves provider-specific extension fields permissive so
|
||||
// the broker can ship new shapes ahead of the client. `raw` is accepted
|
||||
// but normally stripped by the broker before send.
|
||||
return this.#request<UsageResponse>("GET", "/v1/usage", { schema: "usageResponseSchema", signal });
|
||||
/**
|
||||
* Fetch aggregate broker usage with a timeout sized for serialized
|
||||
* same-provider account probes.
|
||||
*/
|
||||
fetchUsage(options: { signal?: AbortSignal; maxAccountsPerProvider?: number } = {}): Promise<UsageResponse> {
|
||||
const requestedAccountCount = options.maxAccountsPerProvider;
|
||||
const accountCount =
|
||||
typeof requestedAccountCount === "number" && Number.isFinite(requestedAccountCount)
|
||||
? Math.max(1, Math.floor(requestedAccountCount))
|
||||
: 1;
|
||||
const perAccountTimeoutMs = Math.max(DEFAULT_TIMEOUT_MS, this.#timeoutMs);
|
||||
const timeoutMs = perAccountTimeoutMs * (accountCount + 1);
|
||||
return this.#request<UsageResponse>("GET", "/v1/usage", {
|
||||
schema: "usageResponseSchema",
|
||||
signal: options.signal,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Recorded usage-limit snapshots from the broker host, oldest first. */
|
||||
@@ -369,7 +380,13 @@ export class AuthBrokerClient {
|
||||
async #request<t>(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
opts: { schema: AuthBrokerResponseSchemaName; auth?: boolean; body?: unknown; signal?: AbortSignal },
|
||||
opts: {
|
||||
schema: AuthBrokerResponseSchemaName;
|
||||
auth?: boolean;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
},
|
||||
): Promise<t> {
|
||||
const response = await this.#fetchRaw(method, path, opts);
|
||||
const text = await response.text();
|
||||
|
||||
@@ -253,6 +253,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
#usageInflight?: Promise<UsageReport[] | null>;
|
||||
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
||||
#usageCacheEpoch = 0;
|
||||
/** Raw broker credentials retained to size aggregate usage requests before account-pool filtering. */
|
||||
#brokerUsageProviderByCredentialId = new Map<number, Provider>();
|
||||
#brokerUsageAccountCounts = new Map<Provider, number>();
|
||||
/** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
|
||||
#usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
|
||||
/** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
|
||||
@@ -296,6 +299,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
|
||||
#applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
|
||||
const nowMs = Date.now();
|
||||
this.#replaceBrokerUsageAccounts(snapshot.credentials);
|
||||
const previousCredentials = this.#snapshot.credentials;
|
||||
const credentials = snapshot.credentials
|
||||
.filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
|
||||
@@ -427,8 +431,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
generation: number,
|
||||
serverNowMs: number,
|
||||
): void {
|
||||
this.#upsertBrokerUsageAccount(entry);
|
||||
if (!isCredentialInAccountPool(entry, this.#accountPool)) {
|
||||
this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs);
|
||||
this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs, { retainBrokerUsageAccount: true });
|
||||
return;
|
||||
}
|
||||
const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
|
||||
@@ -446,7 +451,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#snapshotReceivedAt = Date.now();
|
||||
}
|
||||
|
||||
#removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
|
||||
#removeStreamCredential(
|
||||
id: number,
|
||||
refresher: RefresherSchedule,
|
||||
generation: number,
|
||||
serverNowMs: number,
|
||||
options?: { retainBrokerUsageAccount?: boolean },
|
||||
): void {
|
||||
if (!options?.retainBrokerUsageAccount) this.#removeBrokerUsageAccount(id);
|
||||
const removed = this.#snapshot.credentials.find(entry => entry.id === id);
|
||||
if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
|
||||
const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
||||
@@ -1066,6 +1078,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
});
|
||||
}
|
||||
|
||||
#replaceBrokerUsageAccounts(entries: readonly SnapshotEntry[]): void {
|
||||
this.#brokerUsageProviderByCredentialId.clear();
|
||||
this.#brokerUsageAccountCounts.clear();
|
||||
for (const entry of entries) this.#upsertBrokerUsageAccount(entry);
|
||||
}
|
||||
|
||||
#upsertBrokerUsageAccount(entry: Pick<SnapshotEntry, "id" | "provider">): void {
|
||||
const previous = this.#brokerUsageProviderByCredentialId.get(entry.id);
|
||||
if (previous === entry.provider) return;
|
||||
if (previous !== undefined) {
|
||||
const count = this.#brokerUsageAccountCounts.get(previous) ?? 0;
|
||||
if (count <= 1) this.#brokerUsageAccountCounts.delete(previous);
|
||||
else this.#brokerUsageAccountCounts.set(previous, count - 1);
|
||||
}
|
||||
this.#brokerUsageProviderByCredentialId.set(entry.id, entry.provider);
|
||||
this.#brokerUsageAccountCounts.set(entry.provider, (this.#brokerUsageAccountCounts.get(entry.provider) ?? 0) + 1);
|
||||
}
|
||||
|
||||
#removeBrokerUsageAccount(id: number): void {
|
||||
const provider = this.#brokerUsageProviderByCredentialId.get(id);
|
||||
if (provider === undefined) return;
|
||||
this.#brokerUsageProviderByCredentialId.delete(id);
|
||||
const count = this.#brokerUsageAccountCounts.get(provider) ?? 0;
|
||||
if (count <= 1) this.#brokerUsageAccountCounts.delete(provider);
|
||||
else this.#brokerUsageAccountCounts.set(provider, count - 1);
|
||||
}
|
||||
|
||||
#maxBrokerUsageAccounts(): number {
|
||||
let maximum = 1;
|
||||
for (const count of this.#brokerUsageAccountCounts.values()) maximum = Math.max(maximum, count);
|
||||
return maximum;
|
||||
}
|
||||
|
||||
#loadUsageReports(): Promise<UsageReport[] | null> {
|
||||
const cached = this.#usageCache;
|
||||
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
|
||||
@@ -1074,7 +1119,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
if (this.#usageInflight) return this.#usageInflight;
|
||||
const epoch = this.#usageCacheEpoch;
|
||||
const inflight = this.#client
|
||||
.fetchUsage()
|
||||
.fetchUsage({ maxAccountsPerProvider: this.#maxBrokerUsageAccounts() })
|
||||
.then(body => {
|
||||
if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
|
||||
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
|
||||
|
||||
@@ -741,7 +741,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
||||
}
|
||||
if (req.method === "POST" && pathname === "/v1/usage/stale") {
|
||||
try {
|
||||
opts.storage.invalidateUsageCache?.();
|
||||
await opts.storage.invalidateUsageCache?.();
|
||||
logger.info("auth-broker usage cache invalidated", { peer });
|
||||
return json(200, { ok: true });
|
||||
} catch (error) {
|
||||
|
||||
+146
-47
@@ -677,6 +677,7 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
|
||||
);
|
||||
|
||||
const USAGE_CACHE_PREFIX = "usage_cache:";
|
||||
const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
|
||||
const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
|
||||
const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
|
||||
/**
|
||||
@@ -686,6 +687,12 @@ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
|
||||
* on the next poll.
|
||||
*/
|
||||
const USAGE_FAILURE_BACKOFF_MS = 10_000;
|
||||
/**
|
||||
* A manual invalidation persists across the next CLI process and serializes
|
||||
* same-provider probes, avoiding a cold-account burst against IP-limited
|
||||
* upstream usage endpoints.
|
||||
*/
|
||||
const USAGE_FORCE_REFRESH_TTL_MS = 5 * 60_000;
|
||||
// Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
|
||||
// (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
|
||||
const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
||||
@@ -782,6 +789,7 @@ interface UsageCache {
|
||||
get<T>(key: string): UsageCacheEntry<T> | undefined;
|
||||
getStale<T>(key: string): UsageCacheEntry<T> | undefined;
|
||||
set<T>(key: string, entry: UsageCacheEntry<T>): void;
|
||||
deletePrefix(prefix: string): boolean;
|
||||
cleanup?(): void;
|
||||
}
|
||||
|
||||
@@ -791,6 +799,11 @@ type UsageRequestDescriptor = {
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
type ForcedUsageRefresh = {
|
||||
all: boolean;
|
||||
providers: Set<Provider>;
|
||||
};
|
||||
|
||||
type AuthApiKeyOptions = {
|
||||
baseUrl?: string;
|
||||
modelId?: string;
|
||||
@@ -1185,6 +1198,12 @@ class AuthStorageUsageCache implements UsageCache {
|
||||
this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
|
||||
}
|
||||
|
||||
deletePrefix(prefix: string): boolean {
|
||||
if (!this.store.deleteCachePrefix) return false;
|
||||
this.store.deleteCachePrefix(`${USAGE_CACHE_PREFIX}${prefix}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.store.cleanExpiredCache();
|
||||
}
|
||||
@@ -2955,20 +2974,63 @@ export class AuthStorage {
|
||||
return baseUrl?.trim().replace(/\/+$/, "") ?? "";
|
||||
}
|
||||
|
||||
#usageCacheProviderKey(provider: Provider): string {
|
||||
const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[provider];
|
||||
return versionOverride === undefined ? provider : `${versionOverride}:${provider}`;
|
||||
}
|
||||
|
||||
#usageForceRefreshCacheKey(provider?: Provider): string {
|
||||
return provider
|
||||
? `${USAGE_FORCE_REFRESH_CACHE_PREFIX}provider:${provider}`
|
||||
: `${USAGE_FORCE_REFRESH_CACHE_PREFIX}all`;
|
||||
}
|
||||
|
||||
#markUsageForceRefresh(provider?: Provider): void {
|
||||
this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), {
|
||||
value: true,
|
||||
expiresAt: Date.now() + USAGE_FORCE_REFRESH_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
#hasUsageForceRefresh(provider?: Provider): boolean {
|
||||
const key = this.#usageForceRefreshCacheKey(provider);
|
||||
const entry = this.#usageCache.get<boolean>(key);
|
||||
if (entry?.value !== true) return false;
|
||||
if (entry.expiresAt > Date.now()) return true;
|
||||
this.#usageCache.set(key, { value: null, expiresAt: 0 });
|
||||
return false;
|
||||
}
|
||||
|
||||
#usageForceRefresh(requests: readonly UsageRequestDescriptor[]): ForcedUsageRefresh {
|
||||
const all = this.#hasUsageForceRefresh();
|
||||
const providers = new Set<Provider>();
|
||||
for (const request of requests) providers.add(request.provider);
|
||||
if (!all) {
|
||||
for (const provider of providers) {
|
||||
if (!this.#hasUsageForceRefresh(provider)) providers.delete(provider);
|
||||
}
|
||||
}
|
||||
return { all, providers };
|
||||
}
|
||||
|
||||
#clearUsageForceRefresh(refresh: ForcedUsageRefresh): void {
|
||||
if (refresh.all) this.#usageCache.set(this.#usageForceRefreshCacheKey(), { value: null, expiresAt: 0 });
|
||||
for (const provider of refresh.providers) {
|
||||
this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), { value: null, expiresAt: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
#buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
|
||||
const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
|
||||
const identity = this.#buildUsageCacheIdentity(request.credential);
|
||||
const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
|
||||
const providerKey = versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
|
||||
const providerKey = this.#usageCacheProviderKey(request.provider);
|
||||
return `report:${providerKey}:${baseUrl}:${identity}`;
|
||||
}
|
||||
|
||||
#buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
|
||||
const snapshot = requests
|
||||
.map(request => {
|
||||
const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
|
||||
const providerKey =
|
||||
versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
|
||||
const providerKey = this.#usageCacheProviderKey(request.provider);
|
||||
return `${providerKey}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`;
|
||||
})
|
||||
.sort()
|
||||
@@ -3210,19 +3272,24 @@ export class AuthStorage {
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
|
||||
async #fetchUsageCached(
|
||||
request: UsageRequestDescriptor,
|
||||
options: { timeoutMs?: number; forceRefresh?: boolean } = {},
|
||||
): Promise<UsageReport | null> {
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const forceRefresh = options.forceRefresh ?? false;
|
||||
const cacheKey = this.#buildUsageReportCacheKey(request);
|
||||
const now = Date.now();
|
||||
const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
|
||||
const cached = forceRefresh ? undefined : this.#usageCache.get<UsageReport | null>(cacheKey);
|
||||
// Fresh cache hit: return whatever's there (success or null fallback).
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const inFlight = this.#usageRequestInFlight.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
const usageCacheEpoch = this.#usageCacheEpoch;
|
||||
const inFlightKey = `${cacheKey}\0${usageCacheEpoch}`;
|
||||
const inFlight = this.#usageRequestInFlight.get(inFlightKey);
|
||||
if (inFlight) return inFlight;
|
||||
const promise = (async () => {
|
||||
const report = await this.#fetchUsageUncached(request, timeoutMs);
|
||||
if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
|
||||
@@ -3242,7 +3309,8 @@ export class AuthStorage {
|
||||
// re-hit the endpoint on every poll. Most providers serve the last good
|
||||
// value through transient failures. Session-cookie providers can opt out
|
||||
// so an expired login does not display stale quota indefinitely.
|
||||
const retainLastGood = this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
|
||||
const retainLastGood =
|
||||
!forceRefresh && this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
|
||||
const lastGood = retainLastGood
|
||||
? (this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
|
||||
: null;
|
||||
@@ -3251,10 +3319,10 @@ export class AuthStorage {
|
||||
this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
|
||||
return lastGood;
|
||||
})().finally(() => {
|
||||
this.#usageRequestInFlight.delete(cacheKey);
|
||||
this.#usageRequestInFlight.delete(inFlightKey);
|
||||
});
|
||||
|
||||
this.#usageRequestInFlight.set(cacheKey, promise);
|
||||
this.#usageRequestInFlight.set(inFlightKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -3730,10 +3798,9 @@ export class AuthStorage {
|
||||
if (!resolvedApiKey) return null;
|
||||
usageCredential.apiKey = resolvedApiKey;
|
||||
}
|
||||
return this.#fetchUsageCached(
|
||||
this.#buildUsageRequest(provider, usageCredential, options?.baseUrl),
|
||||
options?.timeoutMs ?? this.#usageRequestTimeoutMs,
|
||||
);
|
||||
return this.#fetchUsageCached(this.#buildUsageRequest(provider, usageCredential, options?.baseUrl), {
|
||||
timeoutMs: options?.timeoutMs ?? this.#usageRequestTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3927,6 +3994,37 @@ export class AuthStorage {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch every requested report, keeping normal polls parallel while a
|
||||
* manually invalidated provider probes accounts one at a time.
|
||||
*/
|
||||
#fetchUsageRequests(
|
||||
requests: readonly UsageRequestDescriptor[],
|
||||
serializedProviders: ReadonlySet<Provider>,
|
||||
): Promise<Array<UsageReport | null>> {
|
||||
const tails = new Map<Provider, Promise<void>>();
|
||||
return Promise.all(
|
||||
requests.map(request => {
|
||||
const forceRefresh = serializedProviders.has(request.provider);
|
||||
if (!forceRefresh) {
|
||||
return this.#fetchUsageCached(request, { timeoutMs: this.#usageRequestTimeoutMs });
|
||||
}
|
||||
const tail = tails.get(request.provider) ?? Promise.resolve();
|
||||
const current = tail.then(() =>
|
||||
this.#fetchUsageCached(request, { timeoutMs: this.#usageRequestTimeoutMs, forceRefresh: true }),
|
||||
);
|
||||
tails.set(
|
||||
request.provider,
|
||||
current.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
return current;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fetchUsageReports(options?: {
|
||||
baseUrlResolver?: (provider: Provider) => string | undefined;
|
||||
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
|
||||
@@ -3945,15 +4043,15 @@ export class AuthStorage {
|
||||
// dispatch + credential selection) coalesce into one upstream call.
|
||||
// Each caller's `signal` only cancels THAT caller's await; the
|
||||
// shared upstream fetch runs to completion so peers aren't punished.
|
||||
const OVERRIDE_KEY = "__override__";
|
||||
let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
|
||||
const overrideKey = `__override__\0${this.#usageCacheEpoch}`;
|
||||
let shared = this.#usageReportsInFlight.get(overrideKey);
|
||||
if (!shared) {
|
||||
// Don't forward the caller signal into the shared fetch — first caller's
|
||||
// abort would otherwise cancel the upstream for every peer.
|
||||
shared = override().finally(() => {
|
||||
this.#usageReportsInFlight.delete(OVERRIDE_KEY);
|
||||
this.#usageReportsInFlight.delete(overrideKey);
|
||||
});
|
||||
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
|
||||
this.#usageReportsInFlight.set(overrideKey, shared);
|
||||
}
|
||||
const reports = await raceUsageWithSignal(shared, options?.signal);
|
||||
if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
|
||||
@@ -3973,7 +4071,8 @@ export class AuthStorage {
|
||||
// a single decorrelation snapshot for 30s, defeating the jitter (some
|
||||
// accounts can be missing from one fetch and present in the next; the
|
||||
// aggregate cache freezes whichever set landed first).
|
||||
const cacheKey = this.#buildUsageReportsCacheKey(requests);
|
||||
const forcedRefresh = this.#usageForceRefresh(requests);
|
||||
const cacheKey = `${this.#buildUsageReportsCacheKey(requests)}\0${this.#usageCacheEpoch}`;
|
||||
|
||||
const inFlight = this.#usageReportsInFlight.get(cacheKey);
|
||||
if (inFlight) return inFlight;
|
||||
@@ -3989,9 +4088,7 @@ export class AuthStorage {
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
|
||||
);
|
||||
const results = await this.#fetchUsageRequests(requests, forcedRefresh.providers);
|
||||
const reports = results.filter((report): report is UsageReport => report !== null);
|
||||
const deduped = this.#dedupeUsageReports(reports);
|
||||
// no outer cache write — see comment above.
|
||||
@@ -4012,6 +4109,7 @@ export class AuthStorage {
|
||||
};
|
||||
}),
|
||||
});
|
||||
this.#clearUsageForceRefresh(forcedRefresh);
|
||||
return resolved;
|
||||
})().finally(() => {
|
||||
this.#usageReportsInFlight.delete(cacheKey);
|
||||
@@ -5729,31 +5827,32 @@ export class AuthStorage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-invalidate cached usage reports so the next fetch retrieves fresh
|
||||
* values from upstream providers. If `provider` is specified, only that
|
||||
* provider's credentials are invalidated; otherwise, all credentials in the
|
||||
* store are invalidated.
|
||||
* Drop report snapshots for a user-requested refresh so a failed probe
|
||||
* cannot replay the pre-invalidation last-good value. The persisted marker
|
||||
* makes the next same-provider refresh serial rather than a cold fan-out.
|
||||
*/
|
||||
async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
|
||||
if (provider) {
|
||||
this.#invalidateUsageReportCache(provider);
|
||||
} else {
|
||||
this.#usageCacheEpoch += 1;
|
||||
const expired = Date.now() - 1;
|
||||
try {
|
||||
const credentials = this.#store.listAuthCredentials();
|
||||
for (const entry of credentials) {
|
||||
if (entry.credential.type !== "oauth") continue;
|
||||
const cacheKey = this.#buildUsageReportCacheKey(
|
||||
this.#buildUsageRequestForOauth(entry.provider, entry.credential),
|
||||
);
|
||||
const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
|
||||
this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug("Failed to list auth credentials for complete usage cache invalidation", { err });
|
||||
async #clearUsageReportCache(provider?: string): Promise<void> {
|
||||
this.#usageCacheEpoch += 1;
|
||||
const prefix = provider ? `report:${this.#usageCacheProviderKey(provider)}:` : "report:";
|
||||
if (!this.#usageCache.deletePrefix(prefix)) {
|
||||
// Third-party stores may not support prefix deletion. Clear every active
|
||||
// request key instead, including API-key and environment credentials.
|
||||
const requests = await this.#collectUsageRequests();
|
||||
for (const request of requests) {
|
||||
if (provider && request.provider !== provider) continue;
|
||||
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
|
||||
}
|
||||
}
|
||||
if (!this.#fetchUsageReportsOverride && !this.#store.fetchUsageReports) this.#markUsageForceRefresh(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard cached usage reports before a user-requested refresh. The next
|
||||
* read probes upstream serially per provider; a failure reports no fresh
|
||||
* usage instead of replaying an invalidated last-good snapshot.
|
||||
*/
|
||||
async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
|
||||
await this.#clearUsageReportCache(provider);
|
||||
|
||||
if (this.#store.invalidateUsageCache) {
|
||||
await this.#store.invalidateUsageCache(signal).catch(err => {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const Flag = {
|
||||
StaleResponsesItem: 0x0010_0000,
|
||||
MalformedFunctionCall: 0x0020_0000,
|
||||
ProviderFinishError: 0x0040_0000,
|
||||
EmptyResponse: 0x0000_2000,
|
||||
ContentBlocked: 0x0000_8000,
|
||||
/** Account-scoped provider policy denial that may succeed with another credential. */
|
||||
AccountPolicy: 0x0000_4000,
|
||||
@@ -50,6 +51,7 @@ const KIND_MASK =
|
||||
Flag.StaleResponsesItem |
|
||||
Flag.MalformedFunctionCall |
|
||||
Flag.ProviderFinishError |
|
||||
Flag.EmptyResponse |
|
||||
Flag.ContentBlocked |
|
||||
Flag.AccountPolicy |
|
||||
Flag.ContextOverflow |
|
||||
@@ -62,7 +64,12 @@ const KIND_MASK =
|
||||
Flag.OAuthExpiry;
|
||||
|
||||
const RETRIABLE_KINDS =
|
||||
Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError;
|
||||
Flag.Transient |
|
||||
Flag.UsageLimit |
|
||||
Flag.ThinkingLoop |
|
||||
Flag.StaleResponsesItem |
|
||||
Flag.ProviderFinishError |
|
||||
Flag.EmptyResponse;
|
||||
|
||||
const OVERFLOW_PATTERNS = [
|
||||
/prompt is too long/i, // Anthropic
|
||||
@@ -104,6 +111,7 @@ const AUTH_FAILURE_PATTERN =
|
||||
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
|
||||
const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
|
||||
const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
|
||||
const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
|
||||
const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
|
||||
const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
|
||||
const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
|
||||
@@ -197,6 +205,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
|
||||
[Flag.StaleResponsesItem, "stale-responses-item"],
|
||||
[Flag.MalformedFunctionCall, "malformed-function-call"],
|
||||
[Flag.ProviderFinishError, "provider-finish-error"],
|
||||
[Flag.EmptyResponse, "empty-response"],
|
||||
[Flag.ContentBlocked, "content-blocked"],
|
||||
[Flag.AccountPolicy, "account-policy"],
|
||||
[Flag.ContextOverflow, "context-overflow"],
|
||||
@@ -340,6 +349,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
||||
if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
|
||||
if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
|
||||
if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
|
||||
if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
|
||||
if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
|
||||
if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
|
||||
if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
|
||||
|
||||
@@ -9,6 +9,8 @@ export type ProviderResponseErrorKind =
|
||||
| "output"
|
||||
/** Response body was empty/missing when content was required. */
|
||||
| "empty-body"
|
||||
/** Response completed without actionable output (for example, thoughts only). */
|
||||
| "empty-output"
|
||||
/** Malformed wire envelope (unexpected message ordering / shape). */
|
||||
| "envelope"
|
||||
/** Content was blocked by a provider safety filter. */
|
||||
@@ -38,11 +40,10 @@ export class ProviderResponseError extends Error {
|
||||
this.kind = options.kind ?? "output";
|
||||
// A safety filter block is terminal and intentionally non-retryable.
|
||||
if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
|
||||
// An incomplete stream (connection dropped / truncated before any terminal
|
||||
// event) or an empty body never produced any content — the request didn't
|
||||
// complete, so it is safe to retry and eligible for model fallback. The
|
||||
// retry layer's replay-unsafe guard still blocks a retry when partial tool
|
||||
// output was already emitted.
|
||||
// A logically empty completed output needs a session-level reminder that
|
||||
// asks for the missing final answer. Empty bodies and incomplete streams
|
||||
// stay on the generic transient retry/model-fallback path.
|
||||
else if (this.kind === "empty-output") attach(this, create(Flag.Transient, Flag.EmptyResponse));
|
||||
else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { extractRetryHint } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Rate limit reason classification and backoff calculation utilities.
|
||||
* Ported from opencode-antigravity-auth plugin for consistency.
|
||||
@@ -5,6 +7,7 @@
|
||||
|
||||
export type RateLimitReason =
|
||||
| "QUOTA_EXHAUSTED"
|
||||
| "INSUFFICIENT_G1_CREDITS_BALANCE"
|
||||
| "RATE_LIMIT_EXCEEDED"
|
||||
| "CONCURRENT_LIMIT"
|
||||
| "MODEL_CAPACITY_EXHAUSTED"
|
||||
@@ -67,6 +70,66 @@ const CN_TRANSIENT_CAP_PATTERN =
|
||||
// of rotating through the opaque-429 fallback.
|
||||
const CN_THROTTLE_PATTERN = /速率(?:限制|过快)|频率(?:过高|过快)|过于频繁|稍后[重再]试/;
|
||||
|
||||
const GOOGLE_RPC_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo";
|
||||
const LONG_RATE_LIMIT_DELAY_MS = 5 * 60 * 1000;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function parseJsonBody(errorMessage: string): Record<string, unknown> | undefined {
|
||||
const start = errorMessage.indexOf("{");
|
||||
const end = errorMessage.lastIndexOf("}");
|
||||
if (start < 0 || end < start) return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(errorMessage.slice(start, end + 1));
|
||||
return asRecord(parsed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify structured Google RESOURCE_EXHAUSTED bodies before consulting text.
|
||||
* Cloud Code Assist prefixes the JSON with its HTTP error label, so accept an
|
||||
* embedded top-level object as well as a raw JSON body.
|
||||
*/
|
||||
function parseGoogleRpcRateLimitReason(errorMessage: string): RateLimitReason | undefined {
|
||||
const body = parseJsonBody(errorMessage);
|
||||
const error = asRecord(body?.error);
|
||||
if (typeof error?.status !== "string" || error.status.trim().toUpperCase() !== "RESOURCE_EXHAUSTED") {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(error.details)) return undefined;
|
||||
|
||||
for (const value of error.details) {
|
||||
const detail = asRecord(value);
|
||||
if (detail?.["@type"] !== GOOGLE_RPC_ERROR_INFO_TYPE || typeof detail.reason !== "string") continue;
|
||||
const reason = detail.reason.trim().toUpperCase();
|
||||
switch (reason) {
|
||||
case "QUOTA_EXHAUSTED":
|
||||
return "QUOTA_EXHAUSTED";
|
||||
case "INSUFFICIENT_G1_CREDITS_BALANCE":
|
||||
// Keep Google's specific credit-balance reason available to logs
|
||||
// and callers while treating it as credential-rotatable below.
|
||||
return "INSUFFICIENT_G1_CREDITS_BALANCE";
|
||||
case "RATE_LIMIT_EXCEEDED": {
|
||||
const retryDelayMs = extractRetryHint(undefined, errorMessage);
|
||||
return retryDelayMs !== undefined && retryDelayMs >= LONG_RATE_LIMIT_DELAY_MS
|
||||
? "QUOTA_EXHAUSTED"
|
||||
: "RATE_LIMIT_EXCEEDED";
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isQuotaExhaustedReason(reason: RateLimitReason): boolean {
|
||||
return reason === "QUOTA_EXHAUSTED" || reason === "INSUFFICIENT_G1_CREDITS_BALANCE";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a rate-limit error message into a reason category.
|
||||
* Priority order: explicit details in a resource-exhausted error > QUOTA
|
||||
@@ -77,6 +140,8 @@ const CN_THROTTLE_PATTERN = /速率(?:限制|过快)|频率(?:过高|过快)|过
|
||||
* Explicit details such as "quota exceeded" retain their normal classification.
|
||||
*/
|
||||
export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
||||
const structuredReason = parseGoogleRpcRateLimitReason(errorMessage);
|
||||
if (structuredReason !== undefined) return structuredReason;
|
||||
const lowerWithStatus = errorMessage.toLowerCase();
|
||||
const lower = lowerWithStatus.replace(RESOURCE_EXHAUSTED_PATTERN, "");
|
||||
const hasResourceExhaustedStatus = lower !== lowerWithStatus;
|
||||
@@ -162,6 +227,7 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
||||
*/
|
||||
export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
|
||||
switch (reason) {
|
||||
case "INSUFFICIENT_G1_CREDITS_BALANCE":
|
||||
case "QUOTA_EXHAUSTED":
|
||||
return QUOTA_EXHAUSTED_BACKOFF_MS;
|
||||
case "RATE_LIMIT_EXCEEDED":
|
||||
@@ -215,6 +281,8 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
|
||||
* credentials.
|
||||
*/
|
||||
export function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean {
|
||||
const structuredReason = message ? parseGoogleRpcRateLimitReason(message) : undefined;
|
||||
if (structuredReason !== undefined) return isQuotaExhaustedReason(structuredReason);
|
||||
// Concurrency caps are shed-and-backoff, not credential-rotatable — but only
|
||||
// for quota-worded 429 / other statuses. HTTP 402 is categorically an
|
||||
// account-billing cap, so a 402 whose body happens to mention concurrency is
|
||||
@@ -235,7 +303,7 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
|
||||
const reason = parseRateLimitReason(message);
|
||||
// For the categorical 402 billing cap a concurrency-worded body is still an
|
||||
// exhausted cap (rotate); for 429 / other only QUOTA_EXHAUSTED rotates.
|
||||
return reason === "QUOTA_EXHAUSTED" || (isBillingCapStatus && reason === "CONCURRENT_LIMIT");
|
||||
return isQuotaExhaustedReason(reason) || (isBillingCapStatus && reason === "CONCURRENT_LIMIT");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,6 +340,8 @@ export function isOpaqueStatusBody(message: string): boolean {
|
||||
* {@link isUsageLimitOutcome} uses it for the account-rotation decision.
|
||||
*/
|
||||
export function matchesUsageLimitText(errorMessage: string): boolean {
|
||||
const structuredReason = parseGoogleRpcRateLimitReason(errorMessage);
|
||||
if (structuredReason !== undefined) return isQuotaExhaustedReason(structuredReason);
|
||||
return (
|
||||
USAGE_LIMIT_PATTERN.test(errorMessage) ||
|
||||
(CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { scheduler } from "node:timers/promises";
|
||||
import { type } from "@oh-my-pi/omptype";
|
||||
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
||||
import {
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||
getAntigravityModelWireProfile,
|
||||
getAntigravityUserAgent,
|
||||
getGeminiCliHeaders,
|
||||
@@ -316,13 +315,6 @@ const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
const ANTIGRAVITY_ENDPOINT_FALLBACKS = [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
|
||||
|
||||
export {
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||
getAntigravityUserAgent,
|
||||
getGeminiCliHeaders,
|
||||
getGeminiCliUserAgent,
|
||||
} from "@oh-my-pi/pi-catalog/wire/gemini-headers";
|
||||
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
@@ -342,11 +334,6 @@ function needsClaudeThinkingBetaHeader(model: Model<"google-gemini-cli">): boole
|
||||
return model.provider === "google-antigravity" && model.id.startsWith("claude-") && model.reasoning;
|
||||
}
|
||||
|
||||
function shouldInjectAntigravitySystemInstruction(modelId: string): boolean {
|
||||
const normalized = modelId.toLowerCase();
|
||||
return normalized.includes("claude") || normalized.includes("gemini-3");
|
||||
}
|
||||
|
||||
const optionalCredentialString = type("unknown").pipe(raw => {
|
||||
const out = type("string")(raw);
|
||||
return out instanceof type.errors ? undefined : out;
|
||||
@@ -635,6 +622,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
const isFlashLeakModel = model.id.includes("flash");
|
||||
|
||||
let started = false;
|
||||
// Once any stream event starts, the endpoint is committed downstream.
|
||||
// Failover remains safe only while `started` is false.
|
||||
let sawFinishReason = false;
|
||||
let lastResponseId: string | undefined;
|
||||
const ensureStarted = () => {
|
||||
@@ -938,6 +927,11 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
};
|
||||
|
||||
let receivedContent = false;
|
||||
const hasThinkingOutput = () =>
|
||||
output.content.some(
|
||||
block =>
|
||||
block.type === "thinking" && (block.thinking.trim().length > 0 || Boolean(block.thinkingSignature)),
|
||||
);
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
const endpoint = endpoints[i];
|
||||
@@ -1027,17 +1021,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
}
|
||||
|
||||
const streamed = await streamResponse(currentResponse);
|
||||
// Only accept an empty STOP as valid silence once every fallback
|
||||
// endpoint is exhausted: an earlier endpoint returning empty
|
||||
// successful streams must still fail over (Antigravity auto mode)
|
||||
// rather than be recorded as a real silent review.
|
||||
// Eventless silence may fail over to the alternate Antigravity
|
||||
// endpoint. Once thinking has streamed, the endpoint is already
|
||||
// committed downstream; Advisor mode may accept that silence,
|
||||
// while normal sessions surface it to final-output recovery.
|
||||
const thoughtOnly = hasThinkingOutput();
|
||||
const acceptedSilence =
|
||||
options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
|
||||
options?.acceptEmptyResponse === true &&
|
||||
!streamed.strippedPlanningLeak &&
|
||||
(isLastEndpoint || thoughtOnly);
|
||||
if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
|
||||
receivedContent = streamed.meaningful || acceptedSilence;
|
||||
break;
|
||||
}
|
||||
|
||||
// A thought-only STOP is a complete provider response, not a
|
||||
// transiently empty transport. Replaying the identical request
|
||||
// burns another full reasoning pass; let session recovery add
|
||||
// an explicit final-output reminder instead.
|
||||
if (thoughtOnly) break;
|
||||
|
||||
if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
|
||||
resetOutput();
|
||||
}
|
||||
@@ -1051,10 +1054,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
}
|
||||
|
||||
if (!receivedContent) {
|
||||
throw new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
|
||||
provider: model.provider,
|
||||
kind: "empty-body",
|
||||
});
|
||||
const thoughtOnly = hasThinkingOutput();
|
||||
throw new AIError.ProviderResponseError(
|
||||
thoughtOnly
|
||||
? "Cloud Code Assist API returned a thought-only response without final output"
|
||||
: "Cloud Code Assist API returned an empty response",
|
||||
{
|
||||
provider: model.provider,
|
||||
kind: thoughtOnly ? "empty-output" : "empty-body",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
@@ -1320,14 +1329,6 @@ export function buildRequest(
|
||||
};
|
||||
}
|
||||
|
||||
if (isAntigravity && shouldInjectAntigravitySystemInstruction(model.id)) {
|
||||
const existingParts = request.systemInstruction?.parts ?? [];
|
||||
request.systemInstruction = {
|
||||
role: "user",
|
||||
parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }, ...existingParts],
|
||||
};
|
||||
}
|
||||
|
||||
if (context.tools && context.tools.length > 0) {
|
||||
const convertedTools = convertTools(context.tools, model);
|
||||
request.tools = isAntigravity ? normalizeAntigravityTools(convertedTools) : convertedTools;
|
||||
|
||||
@@ -233,6 +233,8 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
const parts: Part[] = [];
|
||||
// Check if message is from same provider and model - only then keep thinking blocks
|
||||
const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id;
|
||||
const dropsUnsignedThinking =
|
||||
model.provider === "google-antigravity" && model.id.toLowerCase().includes("claude");
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
@@ -247,6 +249,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
// Skip empty thinking blocks
|
||||
if (!block.thinking || block.thinking.trim() === "") continue;
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
|
||||
if (dropsUnsignedThinking && !thoughtSignature) continue;
|
||||
if (thoughtSignature) {
|
||||
parts.push({
|
||||
thought: true,
|
||||
|
||||
@@ -86,9 +86,13 @@ export async function loginAlibabaTokenPlan(options: OAuthController): Promise<s
|
||||
fetch: options.fetch,
|
||||
});
|
||||
|
||||
const cookieRequestHost =
|
||||
baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL ? "bailian-cs.console.aliyun.com" : "cs-data.qwencloud.com";
|
||||
const rawCookie = await options.onPrompt({
|
||||
message:
|
||||
"Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the cs-data.qwencloud.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip.",
|
||||
baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL
|
||||
? "Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the bailian-cs.console.aliyun.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip."
|
||||
: "Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the cs-data.qwencloud.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip.",
|
||||
placeholder: "name=value; name=value; ...",
|
||||
allowEmpty: true,
|
||||
});
|
||||
@@ -107,7 +111,7 @@ export async function loginAlibabaTokenPlan(options: OAuthController): Promise<s
|
||||
})
|
||||
) {
|
||||
throw new AIError.ConfigurationError(
|
||||
"Invalid QwenCloud Cookie header. Copy the complete Cookie request header from the cs-data.qwencloud.com usage request, not a single cookie value.",
|
||||
`Invalid QwenCloud Cookie header. Copy the complete Cookie request header from the ${cookieRequestHost} usage request, not a single cookie value.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ import { scheduler } from "node:timers/promises";
|
||||
import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
|
||||
import {
|
||||
COPILOT_API_HEADERS,
|
||||
discoverGitHubCopilotApiEndpoint,
|
||||
getGitHubCopilotBaseUrl,
|
||||
isPublicGitHubHost,
|
||||
normalizeDomain,
|
||||
normalizeGitHubCopilotApiEndpoint,
|
||||
normalizeGitHubCopilotEnterpriseDomain,
|
||||
OPENCODE_HEADERS,
|
||||
} from "@oh-my-pi/pi-catalog/wire/github-copilot";
|
||||
@@ -226,27 +226,6 @@ export function refreshGitHubCopilotToken(
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverGitHubCopilotApiEndpoint(token: string, fetchImpl: FetchImpl): Promise<string | undefined> {
|
||||
try {
|
||||
const data = await fetchJson(
|
||||
"https://api.github.com/copilot_internal/user",
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...OPENCODE_HEADERS,
|
||||
},
|
||||
},
|
||||
fetchImpl,
|
||||
);
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const endpoints = (data as { endpoints?: { api?: unknown } }).endpoints;
|
||||
return typeof endpoints?.api === "string" ? normalizeGitHubCopilotApiEndpoint(endpoints.api) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a model for the user's GitHub Copilot account.
|
||||
* This is required for some models (like Claude, Grok) before they can be used.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Antigravity OAuth flow (Gemini 3, Claude, GPT-OSS via Google Cloud)
|
||||
* Uses different OAuth credentials than google-gemini-cli for access to additional models.
|
||||
*/
|
||||
import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
|
||||
import { getAntigravityUserAgent, getAntigravityVersion } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
|
||||
import * as AIError from "../../error";
|
||||
import { oauthFetch, runGoogleOAuthLogin, throwIfLoginCancelled } from "./google-oauth-shared";
|
||||
import type { OAuthController, OAuthCredentials } from "./types";
|
||||
@@ -26,10 +26,12 @@ const SCOPES = [
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const CLOUD_CODE_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const TIER_LEGACY = "legacy-tier";
|
||||
const DAILY_CLOUD_CODE_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
const NODE_API_CLIENT_USER_AGENT = "google-api-nodejs-client/10.3.0";
|
||||
const GOOG_API_CLIENT_HEADER = "gl-node/22.21.1";
|
||||
const TIER_FREE = "free-tier";
|
||||
const PROJECT_ONBOARD_MAX_ATTEMPTS = 5;
|
||||
const PROJECT_ONBOARD_INTERVAL_MS = 2000;
|
||||
|
||||
interface LoadCodeAssistPayload {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
currentTier?: { id?: string };
|
||||
@@ -45,35 +47,65 @@ interface LongRunningOperationResponse {
|
||||
|
||||
export const ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA = Object.freeze({
|
||||
ideType: "ANTIGRAVITY",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
});
|
||||
|
||||
function readProjectId(value: string | { id?: string } | undefined): string | undefined {
|
||||
export interface AntigravityOnboardMetadata {
|
||||
ide_type: string;
|
||||
ide_version: string;
|
||||
ide_name: string;
|
||||
}
|
||||
|
||||
export function getAntigravityOnboardMetadata(): AntigravityOnboardMetadata {
|
||||
return {
|
||||
ide_type: "ANTIGRAVITY",
|
||||
ide_version: getAntigravityVersion(),
|
||||
ide_name: "antigravity",
|
||||
};
|
||||
}
|
||||
|
||||
function readProjectId(value: unknown): string | undefined {
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
return value.trim();
|
||||
}
|
||||
if (value && typeof value === "object" && typeof value.id === "string" && value.id.length > 0) {
|
||||
return value.id;
|
||||
if (value && typeof value === "object" && "id" in value && typeof (value as { id?: unknown }).id === "string") {
|
||||
const id = (value as { id: string }).id.trim();
|
||||
if (id.length > 0) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string {
|
||||
if (!allowedTiers || allowedTiers.length === 0) {
|
||||
return TIER_LEGACY;
|
||||
function extractProjectId(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const record = payload as Record<string, unknown>;
|
||||
for (const key of ["cloudaicompanionProject", "projectId", "project"]) {
|
||||
const id = readProjectId(record[key]);
|
||||
if (id) return id;
|
||||
}
|
||||
const defaultTier = allowedTiers.find(tier => tier.isDefault && typeof tier.id === "string" && tier.id.length > 0);
|
||||
if (defaultTier?.id) {
|
||||
return defaultTier.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDefaultTierId(
|
||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||
currentTier?: { id?: string },
|
||||
): string {
|
||||
if (allowedTiers && allowedTiers.length > 0) {
|
||||
const defaultTier = allowedTiers.find(
|
||||
tier => tier.isDefault && typeof tier.id === "string" && tier.id.trim().length > 0,
|
||||
);
|
||||
if (defaultTier?.id) {
|
||||
return defaultTier.id.trim();
|
||||
}
|
||||
}
|
||||
return TIER_LEGACY;
|
||||
if (currentTier && typeof currentTier.id === "string" && currentTier.id.trim().length > 0) {
|
||||
return currentTier.id.trim();
|
||||
}
|
||||
return TIER_FREE;
|
||||
}
|
||||
|
||||
async function onboardProjectWithRetries(
|
||||
endpoint: string,
|
||||
headers: Record<string, string>,
|
||||
onboardBody: { tierId: string; metadata: typeof ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA },
|
||||
onboardBody: { tier_id: string; metadata: AntigravityOnboardMetadata },
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress?: (message: string) => void,
|
||||
): Promise<string> {
|
||||
@@ -104,7 +136,7 @@ async function onboardProjectWithRetries(
|
||||
continue;
|
||||
}
|
||||
|
||||
const projectId = readProjectId(operation.response?.cloudaicompanionProject);
|
||||
const projectId = extractProjectId(operation.response);
|
||||
if (projectId) {
|
||||
return projectId;
|
||||
}
|
||||
@@ -128,42 +160,65 @@ async function discoverProject(
|
||||
};
|
||||
|
||||
onProgress?.("Checking for existing project...");
|
||||
const endpoint = CLOUD_CODE_ENDPOINT;
|
||||
try {
|
||||
throwIfLoginCancelled(signal);
|
||||
const loadResponse = await oauthFetch(
|
||||
`${endpoint}/v1internal:loadCodeAssist`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
}),
|
||||
},
|
||||
{ provider: "google-antigravity", signal },
|
||||
);
|
||||
let lastErrorText: string | undefined;
|
||||
let lastStatus: number | undefined;
|
||||
let fallbackTierId = TIER_FREE;
|
||||
let loadedSuccessfully = false;
|
||||
|
||||
if (!loadResponse.ok) {
|
||||
const errorText = await loadResponse.text();
|
||||
throw new AIError.OAuthError(
|
||||
`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`,
|
||||
{ kind: "discovery", status: loadResponse.status },
|
||||
for (const endpoint of [DAILY_CLOUD_CODE_ENDPOINT, CLOUD_CODE_ENDPOINT]) {
|
||||
throwIfLoginCancelled(signal);
|
||||
const loadResponse = await oauthFetch(
|
||||
`${endpoint}/v1internal:loadCodeAssist`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
}),
|
||||
},
|
||||
{ provider: "google-antigravity", signal },
|
||||
);
|
||||
|
||||
if (!loadResponse.ok) {
|
||||
lastStatus = loadResponse.status;
|
||||
lastErrorText = await loadResponse.text();
|
||||
continue;
|
||||
}
|
||||
|
||||
loadedSuccessfully = true;
|
||||
const loadPayload = (await loadResponse.json()) as LoadCodeAssistPayload;
|
||||
const existingProject = extractProjectId(loadPayload);
|
||||
if (existingProject) {
|
||||
return existingProject;
|
||||
}
|
||||
fallbackTierId = getDefaultTierId(loadPayload.allowedTiers, loadPayload.currentTier);
|
||||
}
|
||||
|
||||
const loadPayload = (await loadResponse.json()) as LoadCodeAssistPayload;
|
||||
const existingProject = readProjectId(loadPayload.cloudaicompanionProject);
|
||||
if (existingProject) {
|
||||
return existingProject;
|
||||
if (!loadedSuccessfully && lastStatus !== undefined) {
|
||||
throw new AIError.OAuthError(`loadCodeAssist failed: ${lastStatus}: ${lastErrorText || "unknown error"}`, {
|
||||
kind: "discovery",
|
||||
status: lastStatus,
|
||||
});
|
||||
}
|
||||
|
||||
const tierId = getDefaultTierId(loadPayload.allowedTiers);
|
||||
onProgress?.("Provisioning project...");
|
||||
const onboardBody = {
|
||||
tierId,
|
||||
metadata: ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA,
|
||||
tier_id: fallbackTierId,
|
||||
metadata: getAntigravityOnboardMetadata(),
|
||||
};
|
||||
const provisionedProject = await onboardProjectWithRetries(endpoint, headers, onboardBody, signal, onProgress);
|
||||
const onboardHeaders: Record<string, string> = {
|
||||
...headers,
|
||||
"User-Agent": `${headers["User-Agent"]} ${NODE_API_CLIENT_USER_AGENT}`,
|
||||
"X-Goog-Api-Client": GOOG_API_CLIENT_HEADER,
|
||||
};
|
||||
const provisionedProject = await onboardProjectWithRetries(
|
||||
DAILY_CLOUD_CODE_ENDPOINT,
|
||||
onboardHeaders,
|
||||
onboardBody,
|
||||
signal,
|
||||
onProgress,
|
||||
);
|
||||
return provisionedProject;
|
||||
} catch (error) {
|
||||
if (error instanceof AIError.LoginCancelledError || error instanceof AIError.OAuthError) {
|
||||
|
||||
@@ -189,6 +189,7 @@ let providerInFlightHeartbeatWriterOverride:
|
||||
| ((writeProviderInFlightInfo: () => Promise<void>) => Promise<void>)
|
||||
| undefined;
|
||||
let providerInFlightLeaseRemoverOverride: ((leasePath: string) => Promise<void>) | undefined;
|
||||
let providerInFlightWaitObserverOverride: ((provider: string) => void) | undefined;
|
||||
|
||||
export function configureProviderMaxInFlightRequests(limits: Record<string, number> | undefined): void {
|
||||
configuredProviderMaxInFlightRequests = limits ?? {};
|
||||
@@ -489,6 +490,7 @@ function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal):
|
||||
if (signal?.aborted)
|
||||
return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch"));
|
||||
const signalPath = providerInFlightSignalPath(provider);
|
||||
providerInFlightWaitObserverOverride?.(provider);
|
||||
const waitStarted = Date.now();
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||
let settled = false;
|
||||
@@ -615,6 +617,9 @@ export const __providerInFlightForTesting = {
|
||||
setLeaseRemover(remover: ((leasePath: string) => Promise<void>) | undefined): void {
|
||||
providerInFlightLeaseRemoverOverride = remover;
|
||||
},
|
||||
setWaitObserver(observer: ((provider: string) => void) | undefined): void {
|
||||
providerInFlightWaitObserverOverride = observer;
|
||||
},
|
||||
providerDir(provider: string): string {
|
||||
return providerInFlightDir(provider);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { toNumber } from "@oh-my-pi/pi-catalog/utils";
|
||||
import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
|
||||
import {
|
||||
ALIBABA_TOKEN_PLAN_CN_BASE_URL,
|
||||
parseAlibabaTokenPlanCredential,
|
||||
} from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
|
||||
import type {
|
||||
CredentialRankingStrategy,
|
||||
UsageFetchContext,
|
||||
@@ -12,21 +15,45 @@ import { isRecord } from "../utils";
|
||||
import { HOUR_MS, parsePositiveTimestamp, WEEK_MS } from "./shared";
|
||||
|
||||
const PROVIDER = "alibaba-token-plan";
|
||||
const CONSOLE_ORIGIN = "https://home.qwencloud.com";
|
||||
const DASHBOARD_URL = `${CONSOLE_ORIGIN}/billing/subscription/token-plan-individual`;
|
||||
const USER_INFO_URL = `${CONSOLE_ORIGIN}/tool/user/info.json`;
|
||||
const GATEWAY_ACTION = "IntlBroadScopeAspnGateway";
|
||||
const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
|
||||
const USAGE_URL = `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=${GATEWAY_ACTION}&api=${encodeURIComponent(USAGE_API)}`;
|
||||
const BROWSER_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
|
||||
const CONSOLE_CORNERSTONE_PARAM = {
|
||||
domain: "home.qwencloud.com",
|
||||
consoleSite: "QWENCLOUD",
|
||||
console: "ONE_CONSOLE",
|
||||
xsp_lang: "en-US",
|
||||
protocol: "V2",
|
||||
productCode: "p_efm",
|
||||
const INTERNATIONAL_CONSOLE = {
|
||||
origin: "https://home.qwencloud.com",
|
||||
dashboardUrl: "https://home.qwencloud.com/billing/subscription/token-plan-individual",
|
||||
sessionUrl: "https://home.qwencloud.com/tool/user/info.json",
|
||||
gatewayAction: "IntlBroadScopeAspnGateway",
|
||||
region: "ap-southeast-1",
|
||||
usageUrl: `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway&api=${encodeURIComponent(USAGE_API)}`,
|
||||
cornerstoneParam: {
|
||||
domain: "home.qwencloud.com",
|
||||
consoleSite: "QWENCLOUD",
|
||||
console: "ONE_CONSOLE",
|
||||
xsp_lang: "en-US",
|
||||
protocol: "V2",
|
||||
productCode: "p_efm",
|
||||
},
|
||||
} as const;
|
||||
const CHINA_CONSOLE = {
|
||||
origin: "https://bailian.console.aliyun.com",
|
||||
dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
|
||||
sessionUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
|
||||
gatewayAction: "BroadScopeAspnGateway",
|
||||
region: "cn-beijing",
|
||||
usageUrl: `https://bailian-cs.console.aliyun.com/data/api.json?action=BroadScopeAspnGateway&product=sfm_bailian&api=${encodeURIComponent(USAGE_API)}`,
|
||||
cornerstoneParam: {
|
||||
feURL: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal",
|
||||
protocol: "V2",
|
||||
console: "ONE_CONSOLE",
|
||||
productCode: "p_efm",
|
||||
switchAgent: 12608464,
|
||||
switchUserType: 3,
|
||||
domain: "bailian.console.aliyun.com",
|
||||
consoleSite: "BAILIAN_ALIYUN",
|
||||
userNickName: "",
|
||||
userPrincipalName: "",
|
||||
xsp_lang: "zh-CN",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function extractCookieValue(header: string, name: string): string | undefined {
|
||||
@@ -102,35 +129,56 @@ async function fetchAlibabaTokenPlanUsage(
|
||||
const credential = parseAlibabaTokenPlanCredential(params.credential.apiKey);
|
||||
if (!credential?.cookie) return null;
|
||||
const cookie = credential.cookie;
|
||||
const isChina = credential.baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL;
|
||||
const consoleConfig = isChina ? CHINA_CONSOLE : INTERNATIONAL_CONSOLE;
|
||||
|
||||
try {
|
||||
const userResponse = await ctx.fetch(USER_INFO_URL, {
|
||||
const sessionResponse = await ctx.fetch(consoleConfig.sessionUrl, {
|
||||
headers: {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
Accept: isChina
|
||||
? "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8"
|
||||
: "application/json, text/plain, */*",
|
||||
Cookie: cookie,
|
||||
Referer: `${CONSOLE_ORIGIN}/`,
|
||||
Referer: `${consoleConfig.origin}/`,
|
||||
"User-Agent": BROWSER_USER_AGENT,
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!userResponse.ok) {
|
||||
ctx.logger?.warn("QwenCloud session lookup failed", { provider: PROVIDER, status: userResponse.status });
|
||||
if (!sessionResponse.ok) {
|
||||
ctx.logger?.warn("Alibaba Token Plan session lookup failed", {
|
||||
provider: PROVIDER,
|
||||
status: sessionResponse.status,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const userPayload: unknown = await userResponse.json();
|
||||
if (!isRecord(userPayload) || !isRecord(userPayload.data) || typeof userPayload.data.secToken !== "string") {
|
||||
ctx.logger?.warn("QwenCloud session response invalid", { provider: PROVIDER });
|
||||
return null;
|
||||
|
||||
let secToken: string | undefined;
|
||||
let accountId: string | undefined;
|
||||
if (isChina) {
|
||||
const html = await sessionResponse.text();
|
||||
secToken = /\bSEC_TOKEN\s*:\s*"([^"]+)"/.exec(html)?.[1];
|
||||
if (!secToken) {
|
||||
ctx.logger?.warn("Alibaba Token Plan China session response invalid", { provider: PROVIDER });
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const userPayload: unknown = await sessionResponse.json();
|
||||
if (!isRecord(userPayload) || !isRecord(userPayload.data) || typeof userPayload.data.secToken !== "string") {
|
||||
ctx.logger?.warn("QwenCloud session response invalid", { provider: PROVIDER });
|
||||
return null;
|
||||
}
|
||||
secToken = userPayload.data.secToken;
|
||||
accountId = accountIdFromUserData(userPayload.data);
|
||||
}
|
||||
const secToken = userPayload.data.secToken;
|
||||
|
||||
const csrf = extractCookieValue(cookie, "login_aliyunid_csrf") ?? extractCookieValue(cookie, "csrf");
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json, text/plain, */*",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Cookie: cookie,
|
||||
Origin: CONSOLE_ORIGIN,
|
||||
Referer: DASHBOARD_URL,
|
||||
Origin: consoleConfig.origin,
|
||||
Referer: consoleConfig.dashboardUrl,
|
||||
"User-Agent": BROWSER_USER_AGENT,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
};
|
||||
@@ -140,16 +188,21 @@ async function fetchAlibabaTokenPlanUsage(
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
product: "sfm_bailian",
|
||||
action: GATEWAY_ACTION,
|
||||
region: "ap-southeast-1",
|
||||
action: consoleConfig.gatewayAction,
|
||||
region: consoleConfig.region,
|
||||
sec_token: secToken,
|
||||
params: JSON.stringify({
|
||||
Api: USAGE_API,
|
||||
Data: { cornerstoneParam: CONSOLE_CORNERSTONE_PARAM },
|
||||
Data: {
|
||||
cornerstoneParam: {
|
||||
...(isChina ? { feTraceId: crypto.randomUUID() } : {}),
|
||||
...consoleConfig.cornerstoneParam,
|
||||
},
|
||||
},
|
||||
V: "1.0",
|
||||
}),
|
||||
});
|
||||
const usageResponse = await ctx.fetch(USAGE_URL, {
|
||||
const usageResponse = await ctx.fetch(consoleConfig.usageUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
@@ -157,16 +210,18 @@ async function fetchAlibabaTokenPlanUsage(
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!usageResponse.ok) {
|
||||
ctx.logger?.warn("QwenCloud usage fetch failed", { provider: PROVIDER, status: usageResponse.status });
|
||||
ctx.logger?.warn("Alibaba Token Plan usage fetch failed", {
|
||||
provider: PROVIDER,
|
||||
status: usageResponse.status,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const payload: unknown = await usageResponse.json();
|
||||
if (!isRecord(payload) || payload.successResponse === false || !isRecord(payload.data)) {
|
||||
ctx.logger?.warn("QwenCloud usage response invalid", { provider: PROVIDER });
|
||||
ctx.logger?.warn("Alibaba Token Plan usage response invalid", { provider: PROVIDER });
|
||||
return null;
|
||||
}
|
||||
const responseData = unwrapGatewayData(payload.data);
|
||||
const accountId = accountIdFromUserData(userPayload.data);
|
||||
const limits = [
|
||||
buildLimit(
|
||||
"5h",
|
||||
@@ -190,10 +245,10 @@ async function fetchAlibabaTokenPlanUsage(
|
||||
provider: PROVIDER,
|
||||
fetchedAt: Date.now(),
|
||||
limits,
|
||||
metadata: { source: "qwencloud-console", ...(accountId ? { accountId } : {}) },
|
||||
metadata: { source: isChina ? "bailian-console" : "qwencloud-console", ...(accountId ? { accountId } : {}) },
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.logger?.warn("QwenCloud usage request failed", {
|
||||
ctx.logger?.warn("Alibaba Token Plan usage request failed", {
|
||||
provider: PROVIDER,
|
||||
error: error instanceof Error ? error.name : "unknown",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getGeminiCliHeaders } from "../providers/google-gemini-cli";
|
||||
import { getGeminiCliHeaders } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
|
||||
import type {
|
||||
UsageAmount,
|
||||
UsageFetchContext,
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
alibabaTokenPlanRankingStrategy,
|
||||
alibabaTokenPlanUsageProvider,
|
||||
} from "@oh-my-pi/pi-ai/usage/alibaba-token-plan";
|
||||
import { serializeAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
|
||||
import {
|
||||
ALIBABA_TOKEN_PLAN_CN_BASE_URL,
|
||||
serializeAlibabaTokenPlanCredential,
|
||||
} from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
|
||||
|
||||
function params(apiKey: string): UsageFetchParams {
|
||||
return {
|
||||
@@ -102,6 +105,84 @@ describe("QwenCloud Token Plan opt-in usage", () => {
|
||||
expect(windows.secondary?.id).toBe("credits:7d");
|
||||
});
|
||||
|
||||
test("fetches China quota through the Beijing console gateway", async () => {
|
||||
const requests: { url: string; init?: RequestInit }[] = [];
|
||||
const fetchMock: FetchImpl = (input, init) => {
|
||||
requests.push({ url: String(input), init });
|
||||
if (requests.length === 1) {
|
||||
return Promise.resolve(
|
||||
new Response('<script>window.ALIYUN_CONSOLE_CONFIG = { SEC_TOKEN: "cn-sec-token" };</script>'),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
code: "200",
|
||||
data: {
|
||||
DataV2: {
|
||||
data: {
|
||||
data: {
|
||||
per1WeekPercentage: 0.7913113,
|
||||
per1WeekResetTime: 1_786_716_480_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
successResponse: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const cookie = "login_aliyunid_csrf=cn-csrf; aliyun_lang=zh";
|
||||
const credential = serializeAlibabaTokenPlanCredential("sk-sp-beijing", cookie, ALIBABA_TOKEN_PLAN_CN_BASE_URL);
|
||||
|
||||
const report = await alibabaTokenPlanUsageProvider.fetchUsage(params(credential), { fetch: fetchMock });
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.url).toBe("https://bailian.console.aliyun.com/cn-beijing?tab=plan");
|
||||
expect(new Headers(requests[0]?.init?.headers).get("Cookie")).toBe(cookie);
|
||||
expect(requests[1]?.url).toBe(
|
||||
"https://bailian-cs.console.aliyun.com/data/api.json?action=BroadScopeAspnGateway&product=sfm_bailian&api=zeldaHttp.apikeyMgr.%2Ftokenplan%2Fpersonal%2Fapi%2Fv2%2Fusage",
|
||||
);
|
||||
const usageHeaders = new Headers(requests[1]?.init?.headers);
|
||||
expect(usageHeaders.get("Origin")).toBe("https://bailian.console.aliyun.com");
|
||||
expect(usageHeaders.get("Referer")).toBe("https://bailian.console.aliyun.com/cn-beijing?tab=plan");
|
||||
const body = new URLSearchParams(String(requests[1]?.init?.body));
|
||||
expect(body.get("action")).toBe("BroadScopeAspnGateway");
|
||||
expect(body.get("region")).toBe("cn-beijing");
|
||||
expect(body.get("sec_token")).toBe("cn-sec-token");
|
||||
const gatewayParams: unknown = JSON.parse(body.get("params") ?? "null");
|
||||
expect(gatewayParams).toMatchObject({
|
||||
Api: "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage",
|
||||
Data: {
|
||||
cornerstoneParam: {
|
||||
feTraceId: expect.any(String),
|
||||
feURL: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal",
|
||||
protocol: "V2",
|
||||
console: "ONE_CONSOLE",
|
||||
productCode: "p_efm",
|
||||
switchAgent: 12608464,
|
||||
switchUserType: 3,
|
||||
domain: "bailian.console.aliyun.com",
|
||||
consoleSite: "BAILIAN_ALIYUN",
|
||||
userNickName: "",
|
||||
userPrincipalName: "",
|
||||
xsp_lang: "zh-CN",
|
||||
},
|
||||
},
|
||||
V: "1.0",
|
||||
});
|
||||
expect(report).toMatchObject({
|
||||
provider: "alibaba-token-plan",
|
||||
limits: [
|
||||
{
|
||||
id: "credits:7d",
|
||||
window: { id: "7d", durationMs: 604_800_000, resetsAt: 1_786_716_480_000 },
|
||||
amount: { usedFraction: 0.7913113, unit: "percent" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(report?.limits).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("does not claim quota support for API-key-only credentials", async () => {
|
||||
let fetched = false;
|
||||
const fetchMock: FetchImpl = () => {
|
||||
|
||||
@@ -36,10 +36,17 @@ describe("QwenCloud Token Plan login", () => {
|
||||
test("China (Beijing) region validates against and routes inference to cn-beijing", async () => {
|
||||
const authRequests: { url: string; instructions?: string }[] = [];
|
||||
let requestedUrl = "";
|
||||
let cookiePrompt = "";
|
||||
const prompts = ["2", "sk-sp-beijing"];
|
||||
const credential = await loginAlibabaTokenPlan({
|
||||
onAuth: request => authRequests.push(request),
|
||||
onPrompt: async prompt => (prompt.allowEmpty ? "" : (prompts.shift() ?? "")),
|
||||
onPrompt: async prompt => {
|
||||
if (prompt.allowEmpty) {
|
||||
cookiePrompt = prompt.message;
|
||||
return "";
|
||||
}
|
||||
return prompts.shift() ?? "";
|
||||
},
|
||||
fetch: input => {
|
||||
requestedUrl = String(input);
|
||||
return Promise.resolve(Response.json({ data: [{ id: "qwen3.7-plus" }] }));
|
||||
@@ -48,6 +55,7 @@ describe("QwenCloud Token Plan login", () => {
|
||||
|
||||
expect(requestedUrl).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/models");
|
||||
expect(authRequests[0]?.url).toBe("https://www.aliyun.com/benefit/scene/tokenplan");
|
||||
expect(cookiePrompt).toContain("bailian-cs.console.aliyun.com/data/api.json");
|
||||
expect(JSON.parse(credential)).toEqual({
|
||||
token: "sk-sp-beijing",
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
||||
|
||||
@@ -153,6 +153,42 @@ describe("auth-broker wire surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /v1/usage outlives the base timeout for a serialized account batch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const response = Promise.withResolvers<Response>();
|
||||
let usageSignal: AbortSignal | undefined;
|
||||
const fetchImpl: typeof fetch = Object.assign(
|
||||
async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
const signal = init?.signal;
|
||||
if (signal) usageSignal = signal;
|
||||
return response.promise;
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
);
|
||||
const client = new AuthBrokerClient({
|
||||
url: "http://broker.invalid",
|
||||
token,
|
||||
timeoutMs: 10_000,
|
||||
maxRetries: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
try {
|
||||
const usage = client.fetchUsage({ maxAccountsPerProvider: 3 });
|
||||
await Promise.resolve();
|
||||
const baseTimeout = AbortSignal.timeout(10_000);
|
||||
vi.advanceTimersByTime(10_001);
|
||||
await Promise.resolve();
|
||||
expect(baseTimeout.aborted).toBe(true);
|
||||
expect(usageSignal?.aborted).toBe(false);
|
||||
|
||||
const generatedAt = Date.now();
|
||||
response.resolve(Response.json({ generatedAt, reports: [] }));
|
||||
expect(await usage).toEqual({ generatedAt, reports: [] });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /v1/snapshot returns generation headers and 304 for unchanged long-poll", async () => {
|
||||
const res = await fetch(`${handle!.url}/v1/snapshot`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -2354,7 +2354,8 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const refreshDelayMs = 75;
|
||||
const allRefreshesStarted = Promise.withResolvers<void>();
|
||||
const releaseRefreshes = Promise.withResolvers<void>();
|
||||
let inFlight = 0;
|
||||
let maxConcurrent = 0;
|
||||
const refreshStarts: number[] = [];
|
||||
@@ -2362,7 +2363,8 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
refreshStarts.push(Date.now());
|
||||
inFlight += 1;
|
||||
maxConcurrent = Math.max(maxConcurrent, inFlight);
|
||||
await Bun.sleep(refreshDelayMs);
|
||||
if (inFlight === 3) allRefreshesStarted.resolve();
|
||||
await releaseRefreshes.promise;
|
||||
inFlight -= 1;
|
||||
return {
|
||||
...credential,
|
||||
@@ -2378,7 +2380,10 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
{ type: "oauth", ...createCredential("acct-third", "third@example.com"), expires: expiredAt },
|
||||
]);
|
||||
|
||||
const apiKey = await authStorage.getApiKey("openai-codex");
|
||||
const apiKeyPromise = authStorage.getApiKey("openai-codex");
|
||||
await allRefreshesStarted.promise;
|
||||
releaseRefreshes.resolve();
|
||||
const apiKey = await apiKeyPromise;
|
||||
|
||||
expect(apiKey).toBe("refreshed-acct-third");
|
||||
expect(refreshStarts).toHaveLength(3);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
AuthStorage,
|
||||
type StoredAuthCredential,
|
||||
} from "@oh-my-pi/pi-ai/auth-storage";
|
||||
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage";
|
||||
import type { UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage";
|
||||
import { alibabaTokenPlanUsageProvider } from "@oh-my-pi/pi-ai/usage/alibaba-token-plan";
|
||||
import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude";
|
||||
import { serializeAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
|
||||
@@ -326,6 +326,202 @@ describe("AuthStorage usage cache: last-good failure fallback", () => {
|
||||
expect(third).toHaveLength(1);
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it("does not replay last-good quota after an explicit invalidation", async () => {
|
||||
let calls = 0;
|
||||
vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? makeReport("a@example.com") : null;
|
||||
});
|
||||
|
||||
expect(anthropicReports(await storage.fetchUsageReports())).toHaveLength(1);
|
||||
await storage.invalidateUsageCache();
|
||||
|
||||
expect(anthropicReports(await storage.fetchUsageReports())).toHaveLength(0);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthStorage usage cache: explicit invalidation", () => {
|
||||
it("clears cached API-key reports before the next usage read", async () => {
|
||||
const store = makeStore([
|
||||
{
|
||||
id: 1,
|
||||
provider: "zai",
|
||||
credential: { type: "api_key", key: "zai-key" },
|
||||
disabledCause: null,
|
||||
},
|
||||
]);
|
||||
let calls = 0;
|
||||
const usageProvider: UsageProvider = {
|
||||
id: "zai",
|
||||
supports: params => params.provider === "zai" && params.credential.type === "api_key",
|
||||
async fetchUsage() {
|
||||
calls += 1;
|
||||
return {
|
||||
provider: "zai",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "zai:requests:5h",
|
||||
label: "Z.AI Request Quota",
|
||||
scope: { provider: "zai", windowId: "5h" },
|
||||
amount: { used: calls === 1 ? 80 : 20, limit: 100, unit: "requests" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const storage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "zai" ? usageProvider : undefined),
|
||||
});
|
||||
await storage.reload();
|
||||
try {
|
||||
const initial = await storage.fetchUsageReports();
|
||||
expect(initial?.[0]?.limits[0]?.amount.used).toBe(80);
|
||||
|
||||
await storage.invalidateUsageCache();
|
||||
|
||||
const refreshed = await storage.fetchUsageReports();
|
||||
expect(refreshed?.[0]?.limits[0]?.amount.used).toBe(20);
|
||||
expect(calls).toBe(2);
|
||||
} finally {
|
||||
storage.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes a persisted Codex refresh and returns an upgraded plan", async () => {
|
||||
const store = makeStore([
|
||||
{
|
||||
id: 1,
|
||||
provider: "openai-codex",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-free",
|
||||
refresh: "refresh-free",
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId: "account-free",
|
||||
email: "free@example.com",
|
||||
},
|
||||
disabledCause: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
provider: "openai-codex",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-upgraded",
|
||||
refresh: "refresh-upgraded",
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId: "account-upgraded",
|
||||
email: "upgraded@example.com",
|
||||
},
|
||||
disabledCause: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
provider: "openai-codex",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-other",
|
||||
refresh: "refresh-other",
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId: "account-other",
|
||||
email: "other@example.com",
|
||||
},
|
||||
disabledCause: null,
|
||||
},
|
||||
]);
|
||||
let upgraded = false;
|
||||
const refreshStarted = new Map<string, PromiseWithResolvers<void>>();
|
||||
const refreshReleases = new Map<string, PromiseWithResolvers<void>>();
|
||||
for (const accountId of ["account-free", "account-upgraded", "account-other"]) {
|
||||
refreshStarted.set(accountId, Promise.withResolvers<void>());
|
||||
refreshReleases.set(accountId, Promise.withResolvers<void>());
|
||||
}
|
||||
const startedAccounts: string[] = [];
|
||||
const usageProvider: UsageProvider = {
|
||||
id: "openai-codex",
|
||||
supports: params => params.provider === "openai-codex" && params.credential.type === "oauth",
|
||||
async fetchUsage(params) {
|
||||
const accountId = params.credential.accountId;
|
||||
if (!accountId) return null;
|
||||
if (upgraded) {
|
||||
const started = refreshStarted.get(accountId);
|
||||
const release = refreshReleases.get(accountId);
|
||||
if (!started || !release) throw new Error(`unexpected account ${accountId}`);
|
||||
startedAccounts.push(accountId);
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "openai-codex:7d",
|
||||
label: "7 days",
|
||||
scope: { provider: "openai-codex", windowId: "7d" },
|
||||
amount: { used: 0, limit: 100, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
accountId,
|
||||
email: params.credential.email,
|
||||
planType: upgraded && accountId === "account-upgraded" ? "pro" : "free",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const initialStorage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
|
||||
});
|
||||
await initialStorage.reload();
|
||||
try {
|
||||
expect(await initialStorage.fetchUsageReports()).toHaveLength(3);
|
||||
await initialStorage.invalidateUsageCache();
|
||||
} finally {
|
||||
initialStorage.close();
|
||||
}
|
||||
|
||||
upgraded = true;
|
||||
const refreshedStorage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
|
||||
});
|
||||
await refreshedStorage.reload();
|
||||
try {
|
||||
const refresh = refreshedStorage.fetchUsageReports();
|
||||
const freeStarted = refreshStarted.get("account-free");
|
||||
const freeRelease = refreshReleases.get("account-free");
|
||||
if (!freeStarted || !freeRelease) throw new Error("missing free-account refresh gates");
|
||||
await freeStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free"]);
|
||||
freeRelease.resolve();
|
||||
|
||||
const upgradedStarted = refreshStarted.get("account-upgraded");
|
||||
const upgradedRelease = refreshReleases.get("account-upgraded");
|
||||
if (!upgradedStarted || !upgradedRelease) throw new Error("missing upgraded-account refresh gates");
|
||||
await upgradedStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free", "account-upgraded"]);
|
||||
upgradedRelease.resolve();
|
||||
|
||||
const otherStarted = refreshStarted.get("account-other");
|
||||
const otherRelease = refreshReleases.get("account-other");
|
||||
if (!otherStarted || !otherRelease) throw new Error("missing other-account refresh gates");
|
||||
await otherStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free", "account-upgraded", "account-other"]);
|
||||
otherRelease.resolve();
|
||||
|
||||
const reports = await refresh;
|
||||
expect(reports?.find(report => report.metadata?.accountId === "account-upgraded")?.metadata?.planType).toBe(
|
||||
"pro",
|
||||
);
|
||||
} finally {
|
||||
refreshedStorage.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("AuthStorage usage cache: provider failure policy", () => {
|
||||
it("drops stale QwenCloud quota after the optional console session expires", async () => {
|
||||
|
||||
@@ -49,12 +49,12 @@ describe("OAuthCallbackFlow port fallback policy", () => {
|
||||
it("falls back to a random port by default so historical AI-provider flows keep working", async () => {
|
||||
const blocker = occupyLoopbackPort();
|
||||
const progress: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const flow = new TestCallbackFlow(
|
||||
{
|
||||
onAuth: () => {},
|
||||
onAuth: () => controller.abort(new Error("redirect URI observed")),
|
||||
onProgress: msg => progress.push(msg),
|
||||
// Short abort — we only care that the flow advertised the fallback URI.
|
||||
signal: AbortSignal.timeout(100),
|
||||
signal: controller.signal,
|
||||
},
|
||||
{ preferredPort: blocker.port },
|
||||
);
|
||||
|
||||
@@ -88,16 +88,28 @@ describe("AIError.classify — structural provider errors", () => {
|
||||
expect(AIError.retriable(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies an empty provider response as transient + retryable", () => {
|
||||
// Regression: "Cloud Code Assist API returned an empty response" matched no
|
||||
// text pattern and empty-body carried no flag, so retry/model-fallback
|
||||
// chains never engaged and the turn hard-failed.
|
||||
const err = new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
|
||||
provider: "google-antigravity",
|
||||
it("keeps empty response bodies on the generic transient fallback path", () => {
|
||||
const err = new AIError.ProviderResponseError("Google API returned an empty response body", {
|
||||
provider: "google",
|
||||
kind: "empty-body",
|
||||
});
|
||||
const id = AIError.classify(err);
|
||||
expect(AIError.is(id, AIError.Flag.Transient)).toBe(true);
|
||||
expect(AIError.is(id, AIError.Flag.EmptyResponse)).toBe(false);
|
||||
expect(AIError.retriable(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies thought-only output as transient + empty-response + retryable", () => {
|
||||
const err = new AIError.ProviderResponseError(
|
||||
"Cloud Code Assist API returned a thought-only response without final output",
|
||||
{
|
||||
provider: "google-antigravity",
|
||||
kind: "empty-output",
|
||||
},
|
||||
);
|
||||
const id = AIError.classify(err);
|
||||
expect(AIError.is(id, AIError.Flag.Transient)).toBe(true);
|
||||
expect(AIError.is(id, AIError.Flag.EmptyResponse)).toBe(true);
|
||||
expect(AIError.retriable(id)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import { streamGoogle } from "@oh-my-pi/pi-ai/providers/google";
|
||||
import { streamGoogleGeminiCli } from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
|
||||
import { streamGoogleVertex } from "@oh-my-pi/pi-ai/providers/google-vertex";
|
||||
@@ -27,6 +28,24 @@ function ccaChunk(text: string): Record<string, unknown> {
|
||||
return { response: genaiChunk(text) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `{ response: { candidates } }` envelope carrying only a thinking part with `finishReason: STOP` —
|
||||
* the intentional-silence Advisor case (#8480): no visible text and no tool call.
|
||||
*/
|
||||
function ccaThinkingOnlyChunk(thinking: string): Record<string, unknown> {
|
||||
return {
|
||||
response: {
|
||||
candidates: [{ content: { parts: [{ text: thinking, thought: true }] }, finishReason: "STOP" }],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 0,
|
||||
thoughtsTokenCount: 5,
|
||||
totalTokenCount: 15,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(stream: AsyncIterable<AssistantMessageEvent>) {
|
||||
const events: AssistantMessageEvent[] = [];
|
||||
for await (const event of stream) events.push(event);
|
||||
@@ -273,6 +292,33 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => {
|
||||
void events;
|
||||
});
|
||||
|
||||
it("surfaces thought-only STOP immediately for session-level final-output recovery", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock: FetchImpl = async () => {
|
||||
calls += 1;
|
||||
const response = sse(ccaThinkingOnlyChunk("The task is complete, but I omitted the final answer."));
|
||||
Object.defineProperty(response, "url", { value: "https://example.com/v1internal:streamGenerateContent" });
|
||||
return response;
|
||||
};
|
||||
|
||||
const stream = streamGoogleGeminiCli(cliModel, context, {
|
||||
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
|
||||
fetch: fetchMock,
|
||||
});
|
||||
const result = await stream.result();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("thought-only response without final output");
|
||||
expect(AIError.is(result.errorId, AIError.Flag.EmptyResponse)).toBe(true);
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "thinking",
|
||||
thinking: "The task is complete, but I omitted the final answer.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts an empty STOP when silence is a valid caller result", async () => {
|
||||
let calls = 0;
|
||||
const fetchMock: FetchImpl = async () => {
|
||||
@@ -392,6 +438,72 @@ describe("Google empty-response retry (Cloud Code Assist path)", () => {
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts Advisor silence without failover after thought events start", async () => {
|
||||
const requestedEndpoints: string[] = [];
|
||||
const fetchMock: FetchImpl = async input => {
|
||||
const endpoint = endpointFromInput(input);
|
||||
requestedEndpoints.push(endpoint);
|
||||
const response =
|
||||
endpoint === ANTIGRAVITY_SANDBOX_ENDPOINT
|
||||
? sse(ccaChunk("Recovered."))
|
||||
: sse(ccaThinkingOnlyChunk("No concrete risk. I will stay silent."));
|
||||
return withResponseUrl(response, endpoint);
|
||||
};
|
||||
|
||||
const stream = streamGoogleGeminiCli(antigravityModel, context, {
|
||||
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
|
||||
antigravityEndpointMode: "auto",
|
||||
acceptEmptyResponse: true,
|
||||
fetch: fetchMock,
|
||||
});
|
||||
const { events, starts } = await drain(stream);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT]);
|
||||
expect(starts).toBe(1);
|
||||
expect(events.filter(event => event.type === "thinking_start")).toHaveLength(1);
|
||||
expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(1);
|
||||
expect(events.filter(event => event.type === "thinking_end")).toHaveLength(1);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
expect(textOf(result)).toBe("");
|
||||
});
|
||||
|
||||
it("does not fail over a thought-only error after stream events start", async () => {
|
||||
const requestedEndpoints: string[] = [];
|
||||
const fetchMock: FetchImpl = async input => {
|
||||
const endpoint = endpointFromInput(input);
|
||||
requestedEndpoints.push(endpoint);
|
||||
const response =
|
||||
endpoint === ANTIGRAVITY_SANDBOX_ENDPOINT
|
||||
? sse(ccaChunk("Recovered."))
|
||||
: sse(ccaThinkingOnlyChunk("I reasoned but omitted the final answer."));
|
||||
return withResponseUrl(response, endpoint);
|
||||
};
|
||||
|
||||
const stream = streamGoogleGeminiCli(antigravityModel, context, {
|
||||
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
|
||||
antigravityEndpointMode: "auto",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
const { events, starts } = await drain(stream);
|
||||
const result = await stream.result();
|
||||
|
||||
expect(requestedEndpoints).toEqual([ANTIGRAVITY_DAILY_ENDPOINT]);
|
||||
expect(starts).toBe(1);
|
||||
expect(events.filter(event => event.type === "thinking_start")).toHaveLength(1);
|
||||
expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(1);
|
||||
expect(events.filter(event => event.type === "thinking_end")).toHaveLength(1);
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(result.errorMessage).toContain("thought-only response without final output");
|
||||
expect(result.content).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "thinking",
|
||||
thinking: "I reasoned but omitted the final answer.",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
for (const { mode, endpoint } of [
|
||||
{ mode: "production", endpoint: ANTIGRAVITY_DAILY_ENDPOINT },
|
||||
{ mode: "sandbox", endpoint: ANTIGRAVITY_SANDBOX_ENDPOINT },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import * as geminiCliProvider from "@oh-my-pi/pi-ai/providers/google-gemini-cli";
|
||||
import {
|
||||
ANTIGRAVITY_SYSTEM_INSTRUCTION,
|
||||
buildRequest,
|
||||
parseGeminiCliCredentials,
|
||||
shouldRefreshGeminiCliCredentials,
|
||||
@@ -189,9 +188,70 @@ describe("Google Gemini CLI alignment", () => {
|
||||
expect(payload.request.contents).toEqual([{ role: "user", parts: [{ text: "implement token refresh" }] }]);
|
||||
});
|
||||
|
||||
it("drops only unsigned thinking when replaying Antigravity Claude history", () => {
|
||||
const signedThinking = "signed reasoning";
|
||||
const unsignedThinking = "unsigned reasoning";
|
||||
const signature = "c2lnbmVk";
|
||||
const createThinkingContext = (model: Model<"google-gemini-cli">): Context => ({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: signedThinking, thinkingSignature: signature },
|
||||
{ type: "thinking", thinking: unsignedThinking },
|
||||
],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
},
|
||||
{ role: "user", content: "continue", timestamp: 2 },
|
||||
],
|
||||
});
|
||||
const claudeModel = buildModel({
|
||||
...createModel("google-antigravity"),
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
reasoning: true,
|
||||
} as ModelSpec<"google-gemini-cli">);
|
||||
const claudePayload = buildRequest(claudeModel, createThinkingContext(claudeModel), "proj-123", {}, true) as {
|
||||
request: {
|
||||
contents: Array<{
|
||||
role: string;
|
||||
parts: Array<{ text?: string; thought?: boolean; thoughtSignature?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const claudeParts = claudePayload.request.contents.find(content => content.role === "model")?.parts;
|
||||
expect(claudeParts).toEqual([{ thought: true, text: signedThinking, thoughtSignature: signature }]);
|
||||
|
||||
const geminiModel = createModel("google-antigravity");
|
||||
const geminiPayload = buildRequest(geminiModel, createThinkingContext(geminiModel), "proj-123", {}, true) as {
|
||||
request: {
|
||||
contents: Array<{
|
||||
role: string;
|
||||
parts: Array<{ text?: string; thought?: boolean; thoughtSignature?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const geminiParts = geminiPayload.request.contents.find(content => content.role === "model")?.parts ?? [];
|
||||
expect(geminiParts).toContainEqual({ thought: true, text: signedThinking, thoughtSignature: signature });
|
||||
expect(geminiParts.some(part => part.text?.includes(unsignedThinking))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps antigravity metadata in antigravity request payloads", () => {
|
||||
const model = createModel("google-antigravity");
|
||||
const payload = buildRequest(model, createContext(), "proj-123", {}, true) as {
|
||||
const context: Context = { ...createContext(), systemPrompt: ["be terse"] };
|
||||
const payload = buildRequest(model, context, "proj-123", {}, true) as {
|
||||
request: {
|
||||
sessionId?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -324,30 +384,6 @@ describe("Google Gemini CLI alignment", () => {
|
||||
expect(parameters).toBeDefined();
|
||||
expect(JSON.stringify(parameters)).not.toContain('"patternProperties"');
|
||||
});
|
||||
it("injects ANTIGRAVITY_SYSTEM_INSTRUCTION for gemini-3.1-pro-high and gemini-3.1-pro-low", () => {
|
||||
// Regression test for #1274: shouldInjectAntigravitySystemInstruction checked
|
||||
// "gemini-3-pro-high" (hyphen) but the deployed model IDs use "gemini-3.1-pro-high" (dot),
|
||||
// so the injection was silently skipped and the Cloud Code Assist API returned HTTP 400.
|
||||
for (const modelId of ["gemini-3.1-pro-high", "gemini-3.1-pro-low"] as const) {
|
||||
const model: Model<"google-gemini-cli"> = buildModel({
|
||||
...createModel("google-antigravity"),
|
||||
id: modelId,
|
||||
} as ModelSpec<"google-gemini-cli">);
|
||||
const context: Context = {
|
||||
systemPrompt: ["my instructions"],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const payload = buildRequest(model, context, "proj-123", {}, true) as {
|
||||
request: { systemInstruction?: { role?: string; parts: Array<{ text: string }> } };
|
||||
};
|
||||
|
||||
const parts = payload.request.systemInstruction?.parts ?? [];
|
||||
// The antigravity identity header must be injected as the first part.
|
||||
expect(parts[0]?.text).toBe(ANTIGRAVITY_SYSTEM_INSTRUCTION);
|
||||
// The user-supplied system prompt must appear after the single injected part.
|
||||
expect(parts.slice(1).some(p => p.text === "my instructions")).toBe(true);
|
||||
}
|
||||
});
|
||||
it("adds anthropic-beta for Antigravity Claude reasoning models without relying on id suffix", async () => {
|
||||
let requestHeaders: Headers | undefined;
|
||||
const fetchMock: FetchImpl = async (_url, init) => {
|
||||
@@ -401,7 +437,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of sseChunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -464,7 +500,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of sseChunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -577,7 +613,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -624,7 +660,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of sseChunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -668,7 +704,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of sseChunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -821,7 +857,7 @@ describe("Google Gemini CLI alignment", () => {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of sseChunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
await Bun.sleep(5);
|
||||
await Promise.resolve();
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ afterEach(async () => {
|
||||
__providerInFlightForTesting.setHeartbeatTimings(undefined);
|
||||
__providerInFlightForTesting.setHeartbeatWriter(undefined);
|
||||
__providerInFlightForTesting.setLeaseRemover(undefined);
|
||||
__providerInFlightForTesting.setWaitObserver(undefined);
|
||||
if (limiterRoot !== undefined) {
|
||||
await fs.rm(limiterRoot, { recursive: true, force: true });
|
||||
limiterRoot = undefined;
|
||||
@@ -41,6 +42,13 @@ async function useIsolatedLimiterRoot(): Promise<void> {
|
||||
function limiterDir(provider: string): string {
|
||||
return __providerInFlightForTesting.providerDir(provider);
|
||||
}
|
||||
function nextLimiterWait(provider = "tests"): Promise<void> {
|
||||
const waiting = Promise.withResolvers<void>();
|
||||
__providerInFlightForTesting.setWaitObserver(waitingProvider => {
|
||||
if (waitingProvider === provider) waiting.resolve();
|
||||
});
|
||||
return waiting.promise;
|
||||
}
|
||||
|
||||
describe("provider in-flight request limits", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -75,9 +83,9 @@ describe("provider in-flight request limits", () => {
|
||||
const firstResult = first.result();
|
||||
await firstStarted.promise;
|
||||
|
||||
const secondWaiting = nextLimiterWait();
|
||||
const second = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 } });
|
||||
await Bun.sleep(20);
|
||||
expect(mock.calls).toHaveLength(1);
|
||||
await secondWaiting;
|
||||
|
||||
releaseFirst.resolve();
|
||||
const [firstMessage, secondMessage] = await Promise.all([firstResult, second.result()]);
|
||||
@@ -110,27 +118,21 @@ describe("provider in-flight request limits", () => {
|
||||
return undefined;
|
||||
})();
|
||||
const resultPromise = stream.result();
|
||||
const removalOutcome = await Promise.race([
|
||||
removalStarted.promise.then(() => "started" as const),
|
||||
Bun.sleep(2_000).then(() => "blocked" as const),
|
||||
]);
|
||||
if (removalOutcome === "blocked") {
|
||||
allowRemoval.resolve();
|
||||
throw new Error("Provider lease removal did not start");
|
||||
}
|
||||
let completionBeforeRelease: "terminal" | "result" | "pending";
|
||||
try {
|
||||
completionBeforeRelease = await Promise.race([
|
||||
terminalObserved.promise.then(() => "terminal" as const),
|
||||
resultPromise.then(() => "result" as const),
|
||||
Bun.sleep(20).then(() => "pending" as const),
|
||||
]);
|
||||
} finally {
|
||||
allowRemoval.resolve();
|
||||
}
|
||||
await removalStarted.promise;
|
||||
let terminalCompleted = false;
|
||||
let resultCompleted = false;
|
||||
void terminalObserved.promise.then(() => {
|
||||
terminalCompleted = true;
|
||||
});
|
||||
void resultPromise.then(() => {
|
||||
resultCompleted = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(terminalCompleted).toBe(false);
|
||||
expect(resultCompleted).toBe(false);
|
||||
allowRemoval.resolve();
|
||||
const [result, terminalType] = await Promise.all([resultPromise, terminalObservation]);
|
||||
|
||||
expect(completionBeforeRelease).toBe("pending");
|
||||
expect(result.content).toEqual([{ type: "text", text: "reply" }]);
|
||||
expect(terminalType).toBe("done");
|
||||
const entries = await fs.readdir(limiterDir("tests"), { withFileTypes: true });
|
||||
@@ -328,12 +330,13 @@ describe("provider in-flight request limits", () => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
|
||||
const waiting = nextLimiterWait();
|
||||
const stream = streamSimple(mock.model, context(), {
|
||||
maxInFlightRequests: { tests: 1 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await Bun.sleep(150);
|
||||
await waiting;
|
||||
expect(mock.calls).toHaveLength(0);
|
||||
|
||||
await fs.rm(externalLease, { recursive: true, force: true });
|
||||
@@ -355,12 +358,13 @@ describe("provider in-flight request limits", () => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
|
||||
const waiting = nextLimiterWait();
|
||||
const stream = streamSimple(mock.model, context(), {
|
||||
maxInFlightRequests: { tests: 1 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await Bun.sleep(50);
|
||||
await waiting;
|
||||
expect(await Bun.file(path.join(providerDir, ".wakeup")).exists()).toBe(false);
|
||||
expect(mock.calls).toHaveLength(0);
|
||||
|
||||
@@ -403,12 +407,13 @@ describe("provider in-flight request limits", () => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
|
||||
const waiting = nextLimiterWait();
|
||||
const stream = streamSimple(mock.model, context(), {
|
||||
maxInFlightRequests: { tests: 1 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await Bun.sleep(150);
|
||||
await waiting;
|
||||
expect(mock.calls).toHaveLength(0);
|
||||
|
||||
controller.abort(new Error("cancel lock waiter"));
|
||||
@@ -427,12 +432,13 @@ describe("provider in-flight request limits", () => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] });
|
||||
const waiting = nextLimiterWait();
|
||||
const stream = streamSimple(mock.model, context(), {
|
||||
maxInFlightRequests: { tests: 1 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await Bun.sleep(150);
|
||||
await waiting;
|
||||
expect(mock.calls).toHaveLength(0);
|
||||
|
||||
controller.abort(new Error("cancel partial-info waiter"));
|
||||
|
||||
@@ -10,6 +10,30 @@ import {
|
||||
parseRateLimitReason,
|
||||
} from "@oh-my-pi/pi-ai/error/rate-limit";
|
||||
|
||||
function googleRpc429(reason: string, retryDelay?: string, message = "Resource exhausted"): string {
|
||||
const details: Array<Record<string, string>> = [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
reason,
|
||||
domain: "cloudcode-pa.googleapis.com",
|
||||
},
|
||||
];
|
||||
if (retryDelay) {
|
||||
details.push({
|
||||
"@type": "type.googleapis.com/google.rpc.RetryInfo",
|
||||
retryDelay,
|
||||
});
|
||||
}
|
||||
return `Cloud Code Assist API error (429): ${JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message,
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
details,
|
||||
},
|
||||
})}`;
|
||||
}
|
||||
|
||||
describe("parseRateLimitReason", () => {
|
||||
it("classifies Google Quota exceeded as QUOTA_EXHAUSTED", () => {
|
||||
expect(
|
||||
@@ -170,6 +194,54 @@ describe("parseRateLimitReason", () => {
|
||||
),
|
||||
).toBe("QUOTA_EXHAUSTED");
|
||||
});
|
||||
|
||||
it("uses structured QUOTA_EXHAUSTED before capacity message heuristics", () => {
|
||||
const body = googleRpc429(
|
||||
"QUOTA_EXHAUSTED",
|
||||
"21600s",
|
||||
"The model has no capacity available; retry another request later.",
|
||||
);
|
||||
expect(parseRateLimitReason(body)).toBe("QUOTA_EXHAUSTED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps structured RATE_LIMIT_EXCEEDED with a 30s delay transient", () => {
|
||||
const body = googleRpc429("RATE_LIMIT_EXCEEDED", "30s", "Too many requests");
|
||||
expect(parseRateLimitReason(body)).toBe("RATE_LIMIT_EXCEEDED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(false);
|
||||
expect(isUsageLimit(Object.assign(new Error(body), { status: 429 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps structured RATE_LIMIT_EXCEEDED without a retry delay transient", () => {
|
||||
const body = googleRpc429("RATE_LIMIT_EXCEEDED", undefined, "Too many requests");
|
||||
expect(parseRateLimitReason(body)).toBe("RATE_LIMIT_EXCEEDED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats structured RATE_LIMIT_EXCEEDED with a 6h delay as usage exhaustion", () => {
|
||||
const body = googleRpc429("RATE_LIMIT_EXCEEDED", "21600s", "Too many requests");
|
||||
expect(parseRateLimitReason(body)).toBe("QUOTA_EXHAUSTED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the five-minute structured rate-limit threshold as usage exhaustion", () => {
|
||||
const body = googleRpc429("RATE_LIMIT_EXCEEDED", "300s", "Too many requests");
|
||||
expect(parseRateLimitReason(body)).toBe("QUOTA_EXHAUSTED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves structured INSUFFICIENT_G1_CREDITS_BALANCE while rotating credentials", () => {
|
||||
const body = googleRpc429("INSUFFICIENT_G1_CREDITS_BALANCE", undefined, "Credit balance is unavailable");
|
||||
expect(parseRateLimitReason(body)).toBe("INSUFFICIENT_G1_CREDITS_BALANCE");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(true);
|
||||
expect(isUsageLimit(Object.assign(new Error(body), { status: 429 }))).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to existing text heuristics for non-JSON 429 bodies", () => {
|
||||
const body = "Cloud Code Assist API error (429): Too many requests";
|
||||
expect(parseRateLimitReason(body)).toBe("RATE_LIMIT_EXCEEDED");
|
||||
expect(isUsageLimitOutcome(429, body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isUsageLimit", () => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
} from "@oh-my-pi/pi-ai/auth-broker";
|
||||
import { snapshotResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas";
|
||||
import * as oauthUtils from "@oh-my-pi/pi-ai/registry/oauth";
|
||||
import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai/usage";
|
||||
import type { UsageLimit, UsageProvider, UsageReport } from "@oh-my-pi/pi-ai/usage";
|
||||
import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude";
|
||||
import { removeWithRetries } from "../../utils/src/temp";
|
||||
|
||||
function requireLimit(report: UsageReport, id: string): UsageLimit {
|
||||
@@ -33,8 +34,10 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
let serverStorage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
const token = "remote-bearer";
|
||||
let testUsageProviders: Map<string, UsageProvider> | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
testUsageProviders = undefined;
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
@@ -48,7 +51,14 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
accountId: "account-1",
|
||||
email: "a@example.com",
|
||||
});
|
||||
serverStorage = new AuthStorage(serverStore);
|
||||
serverStorage = new AuthStorage(serverStore, {
|
||||
usageProviderResolver: provider =>
|
||||
testUsageProviders
|
||||
? testUsageProviders.get(provider)
|
||||
: provider === "anthropic"
|
||||
? claudeUsage.claudeUsageProvider
|
||||
: undefined,
|
||||
});
|
||||
await serverStorage.reload();
|
||||
handle = startAuthBroker({
|
||||
storage: serverStorage,
|
||||
@@ -59,6 +69,7 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
testUsageProviders = undefined;
|
||||
vi.restoreAllMocks();
|
||||
await handle?.close();
|
||||
serverStorage?.close();
|
||||
@@ -1105,6 +1116,237 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
clientStorage.close();
|
||||
});
|
||||
|
||||
test("broker invalidation drops server-side last-good usage reports", async () => {
|
||||
const credential = serverStore!.listAuthCredentials("anthropic")[0];
|
||||
if (credential?.credential.type !== "oauth") throw new Error("expected OAuth credential");
|
||||
serverStore!.updateAuthCredential(credential.id, {
|
||||
...credential.credential,
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
await serverStorage!.reload();
|
||||
|
||||
let calls = 0;
|
||||
const fetchSpy = vi.spyOn(claudeUsage.claudeUsageProvider, "fetchUsage").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls > 1) return null;
|
||||
return {
|
||||
provider: "anthropic",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "anthropic:5h",
|
||||
label: "Claude 5 Hour",
|
||||
scope: { provider: "anthropic", windowId: "5h" },
|
||||
amount: { used: 80, limit: 100, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
metadata: { accountId: "account-1", email: "a@example.com" },
|
||||
};
|
||||
});
|
||||
const brokerClient = new AuthBrokerClient({ url: handle!.url, token });
|
||||
const initialResult = await brokerClient.fetchSnapshot();
|
||||
if (initialResult.status !== 200) throw new Error("expected snapshot");
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: brokerClient,
|
||||
initialSnapshot: initialResult.snapshot,
|
||||
});
|
||||
const clientStorage = new AuthStorage(remoteStore);
|
||||
await clientStorage.reload();
|
||||
try {
|
||||
expect(await clientStorage.fetchUsageReports()).toHaveLength(1);
|
||||
await clientStorage.invalidateUsageCache();
|
||||
expect(await clientStorage.fetchUsageReports()).toEqual([]);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
clientStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("broker returns an upgraded plan through a delayed serialized Codex refresh", async () => {
|
||||
const accountIds = ["account-free", "account-upgraded", "account-other"];
|
||||
const refreshStarted = new Map<string, PromiseWithResolvers<void>>();
|
||||
const refreshReleases = new Map<string, PromiseWithResolvers<void>>();
|
||||
for (const accountId of accountIds) {
|
||||
refreshStarted.set(accountId, Promise.withResolvers<void>());
|
||||
refreshReleases.set(accountId, Promise.withResolvers<void>());
|
||||
}
|
||||
const startedAccounts: string[] = [];
|
||||
const usageProvider: UsageProvider = {
|
||||
id: "openai-codex",
|
||||
supports: params => params.provider === "openai-codex" && params.credential.type === "oauth",
|
||||
async fetchUsage(params) {
|
||||
const accountId = params.credential.accountId;
|
||||
if (!accountId) return null;
|
||||
const started = refreshStarted.get(accountId);
|
||||
const release = refreshReleases.get(accountId);
|
||||
if (!started || !release) throw new Error(`unexpected account ${accountId}`);
|
||||
startedAccounts.push(accountId);
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [
|
||||
{
|
||||
id: "openai-codex:7d",
|
||||
label: "7 days",
|
||||
scope: { provider: "openai-codex", windowId: "7d" },
|
||||
amount: { used: 0, limit: 100, unit: "percent" },
|
||||
status: "ok",
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
accountId,
|
||||
email: `${accountId.slice("account-".length)}@example.com`,
|
||||
planType: accountId === "account-upgraded" ? "pro" : "free",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
testUsageProviders = new Map([["openai-codex", usageProvider]]);
|
||||
for (const accountId of accountIds) {
|
||||
serverStore!.upsertAuthCredentialForProvider("openai-codex", {
|
||||
type: "oauth",
|
||||
access: `access-${accountId}`,
|
||||
refresh: `refresh-${accountId}`,
|
||||
expires: Date.now() + 3_600_000,
|
||||
accountId,
|
||||
email: `${accountId.slice("account-".length)}@example.com`,
|
||||
});
|
||||
}
|
||||
await serverStorage!.reload();
|
||||
|
||||
const brokerClient = new AuthBrokerClient({ url: handle!.url, token, timeoutMs: 10_000 });
|
||||
const initialResult = await brokerClient.fetchSnapshot();
|
||||
if (initialResult.status !== 200) throw new Error("expected snapshot");
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: brokerClient,
|
||||
initialSnapshot: initialResult.snapshot,
|
||||
});
|
||||
const clientStorage = new AuthStorage(remoteStore);
|
||||
await clientStorage.reload();
|
||||
try {
|
||||
await clientStorage.invalidateUsageCache();
|
||||
const refresh = clientStorage.fetchUsageReports();
|
||||
const freeStarted = refreshStarted.get("account-free");
|
||||
const freeRelease = refreshReleases.get("account-free");
|
||||
if (!freeStarted || !freeRelease) throw new Error("missing free-account refresh gates");
|
||||
await freeStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free"]);
|
||||
freeRelease.resolve();
|
||||
|
||||
const upgradedStarted = refreshStarted.get("account-upgraded");
|
||||
const upgradedRelease = refreshReleases.get("account-upgraded");
|
||||
if (!upgradedStarted || !upgradedRelease) throw new Error("missing upgraded-account refresh gates");
|
||||
await upgradedStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free", "account-upgraded"]);
|
||||
upgradedRelease.resolve();
|
||||
|
||||
const otherStarted = refreshStarted.get("account-other");
|
||||
const otherRelease = refreshReleases.get("account-other");
|
||||
if (!otherStarted || !otherRelease) throw new Error("missing other-account refresh gates");
|
||||
await otherStarted.promise;
|
||||
expect(startedAccounts).toEqual(["account-free", "account-upgraded", "account-other"]);
|
||||
otherRelease.resolve();
|
||||
|
||||
const reports = await refresh;
|
||||
expect(reports).toHaveLength(3);
|
||||
expect(reports?.find(report => report.metadata?.accountId === "account-upgraded")?.metadata?.planType).toBe(
|
||||
"pro",
|
||||
);
|
||||
} finally {
|
||||
for (const release of refreshReleases.values()) release.resolve();
|
||||
clientStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("sizes broker usage timeout from the unfiltered account-pool snapshot", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const credentials: SnapshotResponse["credentials"] = [];
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
const accountId = `account-${index}`;
|
||||
credentials.push({
|
||||
id: index + 1,
|
||||
provider: "openai-codex",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: `access-${index}`,
|
||||
refresh: REMOTE_REFRESH_SENTINEL,
|
||||
expires: now + 120_000,
|
||||
accountId,
|
||||
email: `${index}@example.com`,
|
||||
},
|
||||
identityKey: `email:${index}@example.com`,
|
||||
rotatesInMs: null,
|
||||
});
|
||||
}
|
||||
const visible = credentials[0];
|
||||
if (!visible?.identityKey) throw new Error("expected visible account identity");
|
||||
const initialSnapshot: SnapshotResponse = {
|
||||
generation: 1,
|
||||
generatedAt: now,
|
||||
serverNowMs: now,
|
||||
refresher: { enabled: false, intervalMs: 0, skewMs: 0, nextSweepInMs: Number.MAX_SAFE_INTEGER },
|
||||
credentials,
|
||||
};
|
||||
const response = Promise.withResolvers<Response>();
|
||||
const snapshotResponse = Promise.withResolvers<Response>();
|
||||
let usageSignal: AbortSignal | undefined;
|
||||
const fetchImpl: typeof fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const pathname = new URL(String(input)).pathname;
|
||||
if (pathname === "/v1/snapshot") return snapshotResponse.promise;
|
||||
if (pathname !== "/v1/usage") throw new Error(`unexpected path ${pathname}`);
|
||||
const signal = init?.signal;
|
||||
if (signal) usageSignal = signal;
|
||||
return response.promise;
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
);
|
||||
const brokerClient = new AuthBrokerClient({
|
||||
url: "http://broker.invalid",
|
||||
token: "unused",
|
||||
timeoutMs: 10_000,
|
||||
maxRetries: 0,
|
||||
fetchImpl,
|
||||
});
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: brokerClient,
|
||||
streamSnapshots: false,
|
||||
accountPool: new Map([["openai-codex", new Set([visible.identityKey])]]),
|
||||
initialSnapshot,
|
||||
});
|
||||
try {
|
||||
const usage = remoteStore.fetchUsageReports();
|
||||
await Promise.resolve();
|
||||
const oneAccountBudget = AbortSignal.timeout(20_000);
|
||||
vi.advanceTimersByTime(20_001);
|
||||
await Promise.resolve();
|
||||
expect(oneAccountBudget.aborted).toBe(true);
|
||||
expect(usageSignal?.aborted).toBe(false);
|
||||
|
||||
response.resolve(
|
||||
Response.json({
|
||||
generatedAt: Date.now(),
|
||||
reports: [
|
||||
{
|
||||
provider: "openai-codex",
|
||||
fetchedAt: Date.now(),
|
||||
limits: [],
|
||||
metadata: { accountId: "account-0", email: "0@example.com" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(await usage).toHaveLength(1);
|
||||
} finally {
|
||||
remoteStore.close();
|
||||
snapshotResponse.resolve(new Response(null, { status: 304, headers: { ETag: '"1"' } }));
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
test("account pool exposes only qualified usage reports for visible OAuth identities", async () => {
|
||||
const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" });
|
||||
const now = Date.now();
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added wire constants for Codex V2 remote-compaction feature negotiation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed raw `COPILOT_GITHUB_TOKEN` credentials skipping plan-specific endpoint discovery, which routed GitHub Copilot Business model requests to the personal endpoint and returned HTTP 403. The GitHub Copilot model cache is now scoped per credential, so switching the token no longer serves another account's stale endpoint for the cache TTL ([#8507](https://github.com/can1357/oh-my-pi/issues/8507)).
|
||||
- Fixed the OpenRouter `deepseek/deepseek-v4-pro-0813` route silently clamping the reasoning effort to `high`: the dated SKU advertises (and accepts) the wire-exact `low`/`high`/`max` ladder, so its effort override no longer collapses to `high`-only. The undated `deepseek/deepseek-v4-pro` OpenRouter route stays `high`-only. ([#8517](https://github.com/can1357/oh-my-pi/issues/8517))
|
||||
|
||||
## [17.3.2] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the `deepseek-v4-pro:preview` model
|
||||
- Added support for the `gemini-3.7-flash` model
|
||||
- Added dynamic Antigravity client-version discovery from the official update manifest (darwin/arm64 channel), so version-gated models appear without a code change; `PI_AI_ANTIGRAVITY_VERSION` remains available as an override.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Antigravity discovery missing Gemini 3.7 Flash: Cloud Code Assist gates newer models on the client version in the `User-Agent`, and the pinned `antigravity/hub/2.1.4` was too old. The user-agent now matches the captured 2.8.0 client format (`antigravity/hub/2.8.0 (aidev_client; os_type=darwin; arch=arm64; cl=963137146)`); os_type/arch stay pinned to the darwin/arm64 reference client. Overridable via `PI_AI_ANTIGRAVITY_VERSION` / `PI_AI_ANTIGRAVITY_CL` / `PI_AI_ANTIGRAVITY_OS` / `PI_AI_ANTIGRAVITY_ARCH`.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `ANTIGRAVITY_SYSTEM_INSTRUCTION` from `wire/gemini-headers`; the Antigravity transport and web search no longer inject a fake identity prompt.
|
||||
|
||||
## [17.3.1] - 2026-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added dynamic Antigravity and Gemini CLI discovery support for Gemini 3.7 Flash, with low/medium/high thinking-level routing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated model metadata, context windows, pricing, and configurations in the catalog
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
collapseEffortVariants,
|
||||
type VariantCollapseTable,
|
||||
} from "../variant-collapse";
|
||||
import { getAntigravityUserAgent } from "../wire/gemini-headers";
|
||||
import { ensureAntigravityVersion, getAntigravityUserAgent } from "../wire/gemini-headers";
|
||||
|
||||
export const ANTIGRAVITY_PRIMARY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||
@@ -161,6 +161,10 @@ export interface FetchAntigravityDiscoveryModelsOptions {
|
||||
export async function fetchAntigravityDiscoveryModels(
|
||||
options: FetchAntigravityDiscoveryModelsOptions,
|
||||
): Promise<ModelSpec<"google-gemini-cli">[] | null> {
|
||||
if (options.userAgent === undefined) {
|
||||
await ensureAntigravityVersion(options.fetcher ?? fetch, options.signal);
|
||||
}
|
||||
|
||||
const fetcher = discoveryFetch(options.fetcher);
|
||||
const endpoints = options.endpoint
|
||||
? [trimTrailingSlashes(options.endpoint)]
|
||||
|
||||
@@ -374,13 +374,20 @@ function getModelDefinedEfforts<TApi extends Api>(
|
||||
// on every first-party/aggregator host — the direct API, aggregators, and
|
||||
// Ollama Cloud alike (medium/xhigh fold into high, max is a real wire
|
||||
// tier). See https://api-docs.deepseek.com/api/create-chat-completion.
|
||||
// OpenRouter's non-Flash V4 route still exposes only high; the older
|
||||
// reasoners (V3.x, R1, deepseek-reasoner) top out at high/max.
|
||||
// OpenRouter's non-Flash V4 route exposes only high, except the dated
|
||||
// `deepseek-v4-pro-0813` SKU: its /models metadata advertises (and the
|
||||
// route accepts) the full low/high/max ladder like every other host.
|
||||
// The older reasoners (V3.x, R1, deepseek-reasoner) top out at high/max.
|
||||
if (isDeepseekV4FlashModelId(spec.id)) {
|
||||
return LOW_HIGH_MAX_REASONING_EFFORTS;
|
||||
}
|
||||
if (bareModelId(spec.id).toLowerCase().includes("deepseek-v4")) {
|
||||
return isOpenRouterThinkingFormat(compat) ? HIGH_ONLY_REASONING_EFFORTS : LOW_HIGH_MAX_REASONING_EFFORTS;
|
||||
if (!isOpenRouterThinkingFormat(compat)) {
|
||||
return LOW_HIGH_MAX_REASONING_EFFORTS;
|
||||
}
|
||||
return bareModelId(spec.id).toLowerCase() === "deepseek-v4-pro-0813"
|
||||
? LOW_HIGH_MAX_REASONING_EFFORTS
|
||||
: HIGH_ONLY_REASONING_EFFORTS;
|
||||
}
|
||||
return isOpenRouterThinkingFormat(compat) ? HIGH_ONLY_REASONING_EFFORTS : HIGH_MAX_REASONING_EFFORTS;
|
||||
}
|
||||
|
||||
+694
-355
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
import { PERSONAL_GITHUB_COPILOT_BASE_URL } from "../wire/github-copilot";
|
||||
|
||||
export interface ModelCacheProviderIdOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -56,6 +58,18 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
|
||||
const scope = `${options.apiKey ?? ""}\u0000${discoveryBaseUrl}`;
|
||||
return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`;
|
||||
}
|
||||
case "github-copilot": {
|
||||
// Copilot model specs bake in the plan-specific endpoint (personal vs
|
||||
// Business/Enterprise) resolved from the credential. Discovery writes an
|
||||
// authoritative cache, so `online-if-uncached` serves it for the full
|
||||
// TTL without re-probing. Keying the namespace on the credential means
|
||||
// switching `COPILOT_GITHUB_TOKEN` to a different account misses the
|
||||
// prior endpoint's cache and re-runs discovery instead of hitting the
|
||||
// stale host and 403ing (PR #8510 review).
|
||||
const baseUrl = options.baseUrl ?? PERSONAL_GITHUB_COPILOT_BASE_URL;
|
||||
const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`;
|
||||
return `github-copilot:models-v1:${Bun.hash(scope).toString(36)}`;
|
||||
}
|
||||
case "openrouter":
|
||||
return "openrouter:pseudo-api";
|
||||
case "vllm": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
||||
import * as logger from "@oh-my-pi/pi-utils/logger";
|
||||
import {
|
||||
DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
|
||||
fetchOpenAICompatibleModels,
|
||||
type OpenAICompatibleModelMapperContext,
|
||||
type OpenAICompatibleModelRecord,
|
||||
@@ -25,6 +26,7 @@ import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "..
|
||||
import { coreWeaveProjectHeaders } from "../wire/coreweave";
|
||||
import {
|
||||
COPILOT_API_HEADERS,
|
||||
discoverGitHubCopilotApiEndpoint,
|
||||
getGitHubCopilotBaseUrl,
|
||||
isPersonalGitHubCopilotBaseUrl,
|
||||
parseGitHubCopilotApiKey,
|
||||
@@ -5191,6 +5193,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
const resolveReference = createReferenceResolver(getProviderReferences);
|
||||
return {
|
||||
providerId: "github-copilot",
|
||||
cacheProviderId: resolveModelCacheProviderId("github-copilot", { apiKey: rawApiKey, baseUrl }),
|
||||
dropCachedModelIdsOnStaticMismatch: COPILOT_CACHE_INVALIDATED_MODEL_IDS,
|
||||
// COPILOT_API_HEADERS are compile-time constants (User-Agent + API
|
||||
// version), not credentials. The cache omits all request headers for
|
||||
@@ -5201,11 +5204,17 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
restorableHeaderFallback: { ...COPILOT_API_HEADERS },
|
||||
...(apiKey && {
|
||||
fetchDynamicModels: async () => {
|
||||
const fetchImpl = discoveryFetch(config?.fetch);
|
||||
const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl)
|
||||
? ((await withCatalogDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal =>
|
||||
discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl, signal),
|
||||
)) ?? baseUrl)
|
||||
: baseUrl;
|
||||
const longContextVariants: ModelSpec<Api>[] = [];
|
||||
const models = await fetchOpenAICompatibleModels<Api>({
|
||||
api: "openai-completions",
|
||||
provider: "github-copilot",
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
apiKey,
|
||||
headers: COPILOT_API_HEADERS,
|
||||
mapModel: (
|
||||
@@ -5251,7 +5260,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
const input: ModelSpec<Api>["input"] =
|
||||
supportsVision === true
|
||||
? ["text", "image"]
|
||||
: supportsVision === false || !isPersonalGitHubCopilotBaseUrl(baseUrl)
|
||||
: supportsVision === false || !isPersonalGitHubCopilotBaseUrl(requestBaseUrl)
|
||||
? ["text"]
|
||||
: (reference?.input ?? defaults.input);
|
||||
// With COPILOT_API_HEADERS the served window is the long-context
|
||||
@@ -5272,7 +5281,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
...reference,
|
||||
api,
|
||||
provider: "github-copilot",
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
name,
|
||||
input,
|
||||
contextWindow: defaultTierWindow,
|
||||
@@ -5294,7 +5303,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
: {
|
||||
...defaults,
|
||||
api,
|
||||
baseUrl,
|
||||
baseUrl: requestBaseUrl,
|
||||
name,
|
||||
input,
|
||||
contextWindow: defaultTierWindow,
|
||||
@@ -5340,7 +5349,7 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana
|
||||
}
|
||||
return base;
|
||||
},
|
||||
fetch: config?.fetch,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
if (models === null) {
|
||||
return null;
|
||||
|
||||
@@ -265,22 +265,32 @@ function geminiFlashFamily(mode: "budget" | "google-level"): EffortVariantFamily
|
||||
};
|
||||
}
|
||||
|
||||
const GEMINI_36_FLASH_FAMILY: EffortVariantFamily = {
|
||||
id: "gemini-3.6-flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
members: ["gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high", "gemini-3.6-flash-tiered"],
|
||||
routing: {
|
||||
[Effort.Minimal]: "gemini-3.6-flash-low",
|
||||
[Effort.Low]: "gemini-3.6-flash-low",
|
||||
[Effort.Medium]: "gemini-3.6-flash-medium",
|
||||
[Effort.High]: "gemini-3.6-flash-high",
|
||||
},
|
||||
thinking: {
|
||||
mode: "google-level",
|
||||
efforts: GEMINI_3_FLASH_FAMILY_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Gemini 3.6+ Flash exposes one mandatory-reasoning wire id per thinking
|
||||
* level. Some generations retain additional discovery-only aliases.
|
||||
*/
|
||||
function geminiLevelFlashFamily(version: "3.6" | "3.7", ...additionalMembers: string[]): EffortVariantFamily {
|
||||
const id = `gemini-${version}-flash`;
|
||||
return {
|
||||
id,
|
||||
name: `Gemini ${version} Flash`,
|
||||
members: [`${id}-low`, `${id}-medium`, `${id}-high`, ...additionalMembers],
|
||||
routing: {
|
||||
[Effort.Minimal]: `${id}-low`,
|
||||
[Effort.Low]: `${id}-low`,
|
||||
[Effort.Medium]: `${id}-medium`,
|
||||
[Effort.High]: `${id}-high`,
|
||||
},
|
||||
thinking: {
|
||||
mode: "google-level",
|
||||
efforts: GEMINI_3_FLASH_FAMILY_EFFORTS,
|
||||
requiresEffort: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const GEMINI_36_FLASH_FAMILY = geminiLevelFlashFamily("3.6", "gemini-3.6-flash-tiered");
|
||||
const GEMINI_37_FLASH_FAMILY = geminiLevelFlashFamily("3.7");
|
||||
|
||||
function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily {
|
||||
const budget = mode === "budget";
|
||||
@@ -364,13 +374,20 @@ const SHARED_CCA_FAMILIES: readonly EffortVariantFamily[] = [
|
||||
|
||||
/** `google-antigravity` Gemini families, using each generation's native transport. */
|
||||
export const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
families: [GEMINI_36_FLASH_FAMILY, geminiFlashFamily("budget"), geminiProFamily("budget"), ...SHARED_CCA_FAMILIES],
|
||||
families: [
|
||||
GEMINI_36_FLASH_FAMILY,
|
||||
GEMINI_37_FLASH_FAMILY,
|
||||
geminiFlashFamily("budget"),
|
||||
geminiProFamily("budget"),
|
||||
...SHARED_CCA_FAMILIES,
|
||||
],
|
||||
};
|
||||
|
||||
/** `google-gemini-cli` Gemini families on the official CLI's level transport. */
|
||||
export const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = {
|
||||
families: [
|
||||
GEMINI_36_FLASH_FAMILY,
|
||||
GEMINI_37_FLASH_FAMILY,
|
||||
geminiFlashFamily("google-level"),
|
||||
geminiProFamily("google-level"),
|
||||
...SHARED_CCA_FAMILIES,
|
||||
|
||||
@@ -11,6 +11,8 @@ export const CODEX_CLIENT_VERSION = "0.144.1";
|
||||
|
||||
export const OPENAI_HEADERS = {
|
||||
BETA: "OpenAI-Beta",
|
||||
/** Codex feature-negotiation header; values identify opt-in wire protocols. */
|
||||
CODEX_BETA_FEATURES: "x-codex-beta-features",
|
||||
ACCOUNT_ID: "chatgpt-account-id",
|
||||
ORIGINATOR: "originator",
|
||||
VERSION: "version",
|
||||
@@ -32,6 +34,7 @@ export const OPENAI_HEADERS = {
|
||||
export const OPENAI_HEADER_VALUES = {
|
||||
BETA_RESPONSES: "responses=experimental",
|
||||
BETA_RESPONSES_WEBSOCKETS_V2: "responses_websockets=2026-02-06",
|
||||
REMOTE_COMPACTION_V2: "remote_compaction_v2",
|
||||
ORIGINATOR_CODEX: "pi",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { FetchImpl } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Build a User-Agent string that identifies as Gemini CLI to unlock higher rate limits.
|
||||
* Uses the same format as the official Gemini CLI (v0.35+):
|
||||
@@ -15,30 +17,89 @@ export const getGeminiCliHeaders = (modelId?: string) => ({
|
||||
"Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
|
||||
});
|
||||
|
||||
export const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
|
||||
"You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." +
|
||||
"**Absolute paths only**" +
|
||||
"**Proactiveness**";
|
||||
/**
|
||||
* Antigravity / Cloud Code Assist user agent. Lives in its own file so discovery
|
||||
* and usage code can read it without pulling the heavy google-gemini-cli provider
|
||||
* (and its @google/genai → google-auth-library dependency chain) into the startup
|
||||
* parse graph.
|
||||
*
|
||||
* Format captured from the real 2.8.0 `antigravity/hub` client:
|
||||
* `antigravity/hub/2.8.0 (aidev_client; os_type=darwin; arch=arm64; cl=963137146)`.
|
||||
* The backend gates newer models (e.g. gemini-3.7-flash) on the client version,
|
||||
* so the version tracks the latest Antigravity release via the update manifest
|
||||
* (see {@link ensureAntigravityVersion}) with `DEFAULT_ANTIGRAVITY_VERSION` as
|
||||
* the offline fallback. os_type/arch are pinned to the darwin/arm64 reference
|
||||
* client the version and manifest are captured from, independent of the host
|
||||
* platform. Overrides: PI_AI_ANTIGRAVITY_VERSION / _CL / _OS / _ARCH.
|
||||
*/
|
||||
export let getAntigravityUserAgent = () => {
|
||||
const DEFAULT_ANTIGRAVITY_VERSION = "2.1.4";
|
||||
const version = process.env.PI_AI_ANTIGRAVITY_VERSION || DEFAULT_ANTIGRAVITY_VERSION;
|
||||
// Map Node.js platform/arch to Antigravity's expected format.
|
||||
// Verified against Antigravity source: _qn() and wqn() in main.js.
|
||||
// process.platform: win32→windows, others pass through (darwin, linux)
|
||||
// process.arch: x64→amd64, ia32→386, others pass through (arm64)
|
||||
const os = process.platform === "win32" ? "windows" : process.platform;
|
||||
const arch = process.arch === "x64" ? "amd64" : process.arch === "ia32" ? "386" : process.arch;
|
||||
const userAgent = `antigravity/hub/${version} ${os}/${arch}`;
|
||||
getAntigravityUserAgent = () => userAgent;
|
||||
return userAgent;
|
||||
};
|
||||
export const DEFAULT_ANTIGRAVITY_VERSION = "2.8.0";
|
||||
|
||||
const ANTIGRAVITY_VERSION_MANIFEST_URL =
|
||||
"https://antigravity-hub-auto-updater-974169037036.us-central1.run.app/manifest/latest-arm64-mac.yml";
|
||||
const ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS = 5_000;
|
||||
|
||||
let discoveredAntigravityVersion: string | null = null;
|
||||
let antigravityVersionFetch: Promise<void> | null = null;
|
||||
|
||||
/** Current Antigravity client version: env override → manifest-discovered → pinned fallback. */
|
||||
export function getAntigravityVersion(): string {
|
||||
return process.env.PI_AI_ANTIGRAVITY_VERSION || discoveredAntigravityVersion || DEFAULT_ANTIGRAVITY_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the client version from an electron-builder update manifest.
|
||||
* Returns null when no well-formed `version:` line is present.
|
||||
*/
|
||||
export function parseAntigravityManifestVersion(yamlText: string): string | null {
|
||||
for (const line of yamlText.split(/\r?\n/)) {
|
||||
const match = /^\s*version\s*:\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))\s*(?:#.*)?$/.exec(line);
|
||||
if (!match) continue;
|
||||
const version = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
||||
return /^\d+\.\d+\.\d+$/.test(version) ? version : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the latest Antigravity release from the official update manifest.
|
||||
* Success is cached for the process lifetime; failures are silent (the pinned
|
||||
* fallback stays valid) and clear the in-flight cache so a later call retries.
|
||||
* Skipped entirely when PI_AI_ANTIGRAVITY_VERSION is set.
|
||||
*/
|
||||
export function ensureAntigravityVersion(fetcher: FetchImpl = fetch, signal?: AbortSignal): Promise<void> {
|
||||
if (process.env.PI_AI_ANTIGRAVITY_VERSION || discoveredAntigravityVersion) return Promise.resolve();
|
||||
if (antigravityVersionFetch) return antigravityVersionFetch;
|
||||
|
||||
antigravityVersionFetch = (async () => {
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS);
|
||||
const response = await fetcher(ANTIGRAVITY_VERSION_MANIFEST_URL, {
|
||||
headers: { "Cache-Control": "no-cache", "User-Agent": "electron-builder" },
|
||||
signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal,
|
||||
});
|
||||
if (response.ok) {
|
||||
discoveredAntigravityVersion = parseAntigravityManifestVersion(await response.text());
|
||||
}
|
||||
} catch {
|
||||
// Silent: the pinned fallback remains valid when version discovery fails.
|
||||
} finally {
|
||||
if (!discoveredAntigravityVersion) antigravityVersionFetch = null;
|
||||
}
|
||||
})();
|
||||
return antigravityVersionFetch;
|
||||
}
|
||||
|
||||
/** Antigravity `User-Agent` header value; rebuilt when the discovered version changes. */
|
||||
export function getAntigravityUserAgent(): string {
|
||||
const version = getAntigravityVersion();
|
||||
// The backend does not validate `cl` (verified live: stale, zero, and absent
|
||||
// cl all pass model gating on daily-cloudcode-pa; only the version gates).
|
||||
// The update manifest carries no changelist, so the captured value stays.
|
||||
const cl = process.env.PI_AI_ANTIGRAVITY_CL || "963137146";
|
||||
const os = process.env.PI_AI_ANTIGRAVITY_OS || "darwin";
|
||||
const arch = process.env.PI_AI_ANTIGRAVITY_ARCH || "arm64";
|
||||
return `antigravity/hub/${version} (aidev_client; os_type=${os}; arch=${arch}; cl=${cl})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wire-id Antigravity Cloud Code Assist request constants, captured from the
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { FetchImpl } from "../types";
|
||||
import { isRecord } from "../utils";
|
||||
|
||||
/**
|
||||
* GitHub Copilot wire metadata: API-key envelope parsing and endpoint
|
||||
* derivation shared by catalog discovery and the pi-ai OAuth flow. The device
|
||||
@@ -72,6 +75,35 @@ export function normalizeGitHubCopilotApiEndpoint(input: string | undefined): st
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Resolve the plan-specific Copilot API endpoint advertised for a GitHub token.
|
||||
* Login and raw environment-token discovery share this best-effort probe. Pass
|
||||
* a `signal` to bound it against the same discovery deadline as `/models`; a
|
||||
* stalled probe otherwise blocks discovery indefinitely.
|
||||
*/
|
||||
export async function discoverGitHubCopilotApiEndpoint(
|
||||
token: string,
|
||||
fetchImpl: FetchImpl,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetchImpl("https://api.github.com/copilot_internal/user", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...OPENCODE_HEADERS,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const data: unknown = await response.json();
|
||||
if (!isRecord(data) || !isRecord(data.endpoints)) return undefined;
|
||||
const endpoint = data.endpoints.api;
|
||||
return typeof endpoint === "string" ? normalizeGitHubCopilotApiEndpoint(endpoint) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubCopilotApiKey(apiKeyRaw: string): ParsedGitHubCopilotApiKey {
|
||||
try {
|
||||
|
||||
@@ -1,33 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { createReferenceResolver } from "../src/provider-models/bundled-references";
|
||||
import type { ModelSpec } from "../src/types";
|
||||
|
||||
const FIXTURE = `${import.meta.dir}/fixtures/bundled-reference-laziness.ts`;
|
||||
|
||||
async function runFixture(fixture: string): Promise<string> {
|
||||
const tempDir = TempDir.createSync("@pi-catalog-bundled-reference-laziness-");
|
||||
const resultPath = tempDir.join("result.json");
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, fixture],
|
||||
env: { ...process.env, OMP_CATALOG_LAZINESS_RESULT_PATH: resultPath },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||||
return await Bun.file(resultPath).text();
|
||||
} finally {
|
||||
tempDir.removeSync();
|
||||
}
|
||||
function runFixture(fixture: string): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, fixture],
|
||||
env: process.env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||||
return result.stdout.toString();
|
||||
}
|
||||
|
||||
describe("bundled reference laziness", () => {
|
||||
test("constructing bundled model-manager options retains less than 8 MiB of RSS", async () => {
|
||||
const { retainedRssBytes } = JSON.parse(await runFixture(FIXTURE)) as { retainedRssBytes: number };
|
||||
describe("bundled model laziness", () => {
|
||||
test("provider options and the bundled registry stay lazy", () => {
|
||||
const { retainedRssBytes } = JSON.parse(runFixture(FIXTURE)) as { retainedRssBytes: number };
|
||||
expect(retainedRssBytes).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 60_000);
|
||||
|
||||
test("a lazy provider-reference factory initializes on first resolution and only once", () => {
|
||||
const reference = {
|
||||
id: "fixture-model",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { expect, spyOn } from "bun:test";
|
||||
import * as buildModule from "../../src/build";
|
||||
import type { GeneratedProvider } from "../../src/models";
|
||||
import { ollamaCloudModelManagerOptions } from "../../src/provider-models/ollama";
|
||||
import { nanoGptModelManagerOptions } from "../../src/provider-models/openai-compat";
|
||||
|
||||
@@ -8,10 +11,48 @@ ollamaCloudModelManagerOptions();
|
||||
Bun.gc(true);
|
||||
const retainedRssBytes = process.memoryUsage().rss - rssBefore;
|
||||
|
||||
const result = JSON.stringify({ retainedRssBytes });
|
||||
const resultPath = process.env.OMP_CATALOG_LAZINESS_RESULT_PATH;
|
||||
if (resultPath) {
|
||||
await Bun.write(resultPath, result);
|
||||
} else {
|
||||
process.stdout.write(result);
|
||||
process.stdout.write(JSON.stringify({ retainedRssBytes }));
|
||||
|
||||
// Keep the model-registry import below the RSS assertion setup: importing it
|
||||
// eagerly loads models.json and would invalidate the provider-option laziness
|
||||
// measurement above. This same isolated process can then verify the registry's
|
||||
// own lazy, per-provider enrichment without paying for a second Bun startup.
|
||||
const { getBundledModel, getBundledModels, getBundledProviders } = await import("../../src/models");
|
||||
const { default: MODELS } = await import("../../src/models.json", { with: { type: "json" } });
|
||||
const buildSpy = spyOn(buildModule, "buildModel");
|
||||
const rawProviders = Object.keys(MODELS);
|
||||
const firstProviders = getBundledProviders();
|
||||
const secondProviders = getBundledProviders();
|
||||
|
||||
expect(buildSpy).toHaveBeenCalledTimes(0);
|
||||
expect(firstProviders as string[]).toEqual(rawProviders);
|
||||
expect(secondProviders as string[]).toEqual(rawProviders);
|
||||
expect(secondProviders).not.toBe(firstProviders);
|
||||
|
||||
const provider = "sakana" satisfies GeneratedProvider;
|
||||
const rawModelIds = Object.keys(MODELS[provider]);
|
||||
const firstModels = getBundledModels(provider);
|
||||
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
expect(firstModels.map(model => model.id)).toEqual(rawModelIds);
|
||||
|
||||
const secondModels = getBundledModels(provider);
|
||||
expect(secondModels).not.toBe(firstModels);
|
||||
expect(secondModels).toHaveLength(firstModels.length);
|
||||
for (let index = 0; index < firstModels.length; index++) {
|
||||
expect(secondModels[index]).toBe(firstModels[index]);
|
||||
}
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
const firstModelId = rawModelIds[0];
|
||||
if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`);
|
||||
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
|
||||
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
const unknownProvider = "not-a-bundled-provider" as GeneratedProvider;
|
||||
expect(getBundledModels(unknownProvider)).toEqual([]);
|
||||
expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined();
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
buildSpy.mockRestore();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { expect, spyOn } from "bun:test";
|
||||
import * as buildModule from "../../src/build";
|
||||
import { type GeneratedProvider, getBundledModel, getBundledModels, getBundledProviders } from "../../src/models";
|
||||
import MODELS from "../../src/models.json" with { type: "json" };
|
||||
|
||||
const buildSpy = spyOn(buildModule, "buildModel");
|
||||
const rawProviders = Object.keys(MODELS);
|
||||
const firstProviders = getBundledProviders();
|
||||
const secondProviders = getBundledProviders();
|
||||
|
||||
expect(buildSpy).toHaveBeenCalledTimes(0);
|
||||
expect(firstProviders as string[]).toEqual(rawProviders);
|
||||
expect(secondProviders as string[]).toEqual(rawProviders);
|
||||
expect(secondProviders).not.toBe(firstProviders);
|
||||
|
||||
const provider = "sakana" satisfies GeneratedProvider;
|
||||
const rawModelIds = Object.keys(MODELS[provider]);
|
||||
const firstModels = getBundledModels(provider);
|
||||
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
expect(firstModels.map(model => model.id)).toEqual(rawModelIds);
|
||||
|
||||
const secondModels = getBundledModels(provider);
|
||||
expect(secondModels).not.toBe(firstModels);
|
||||
expect(secondModels).toHaveLength(firstModels.length);
|
||||
for (let index = 0; index < firstModels.length; index++) {
|
||||
expect(secondModels[index]).toBe(firstModels[index]);
|
||||
}
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
const firstModelId = rawModelIds[0];
|
||||
if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`);
|
||||
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
|
||||
expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]);
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
const unknownProvider = "not-a-bundled-provider" as GeneratedProvider;
|
||||
expect(getBundledModels(unknownProvider)).toEqual([]);
|
||||
expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined();
|
||||
expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length);
|
||||
|
||||
buildSpy.mockRestore();
|
||||
@@ -42,6 +42,13 @@ async function discoverCopilotModels(
|
||||
const requestApiVersions: Array<string | undefined> = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
expect(getHeaderValue(init?.headers, "Authorization")).toBe(`token ${expectedAuthorizationToken}`);
|
||||
// The probe must be bounded by the shared discovery deadline so a
|
||||
// stalled endpoint cannot hang discovery (PR #8510 review).
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
return Response.json({ endpoints: { api: expectedBaseUrl } });
|
||||
}
|
||||
expect(url).toBe(`${expectedBaseUrl}/models`);
|
||||
expect(init?.method).toBe("GET");
|
||||
expect(getHeaderValue(init?.headers, "Authorization")).toBe(`Bearer ${expectedAuthorizationToken}`);
|
||||
@@ -72,13 +79,77 @@ function cachedCopilotCompletionModel(id: string, name: string): ModelSpec<"open
|
||||
}
|
||||
|
||||
describe("github copilot model limits mapping", () => {
|
||||
it("uses configured base URL for discovery", async () => {
|
||||
it("discovers the plan endpoint for a raw environment token before model discovery", async () => {
|
||||
const token = "ghu_valid_business_token";
|
||||
const { fetchMock } = await discoverCopilotModels(
|
||||
{ data: [] },
|
||||
"copilot-test-key",
|
||||
"https://api.githubcopilot.com",
|
||||
token,
|
||||
"https://api.business.githubcopilot.com",
|
||||
token,
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("falls back to the personal endpoint when the raw-token probe fails", async () => {
|
||||
const token = "ghu_valid_business_token";
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
throw new DOMException("The operation timed out.", "TimeoutError");
|
||||
}
|
||||
expect(url).toBe("https://api.githubcopilot.com/models");
|
||||
return Response.json({ data: [] });
|
||||
});
|
||||
const models = await githubCopilotModelManagerOptions({ apiKey: token, fetch: fetchMock }).fetchDynamicModels?.();
|
||||
expect(models).toEqual([]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("does not reuse another token's authoritative cache after COPILOT_GITHUB_TOKEN switches", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-copilot-token-switch-"));
|
||||
const cacheDbPath = path.join(tempDir, "models.db");
|
||||
try {
|
||||
const personalFetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
return Response.json({ endpoints: { api: "https://api.githubcopilot.com" } });
|
||||
}
|
||||
if (url === "https://api.githubcopilot.com/models") {
|
||||
return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] });
|
||||
}
|
||||
throw new Error(`unexpected personal request: ${url}`);
|
||||
});
|
||||
const personalManager = createModelManager({
|
||||
...githubCopilotModelManagerOptions({ apiKey: "ghu_personal_token", fetch: personalFetch }),
|
||||
cacheDbPath,
|
||||
});
|
||||
// Personal token discovery writes a fresh authoritative cache.
|
||||
await personalManager.refresh("online");
|
||||
|
||||
const businessSeen: string[] = [];
|
||||
const businessFetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
businessSeen.push(url);
|
||||
if (url === "https://api.github.com/copilot_internal/user") {
|
||||
return Response.json({ endpoints: { api: "https://api.business.githubcopilot.com" } });
|
||||
}
|
||||
if (url === "https://api.business.githubcopilot.com/models") {
|
||||
return Response.json({ data: [{ id: "gpt-5.5", name: "GPT-5.5" }] });
|
||||
}
|
||||
throw new Error(`unexpected business request: ${url}`);
|
||||
});
|
||||
const businessManager = createModelManager({
|
||||
...githubCopilotModelManagerOptions({ apiKey: "ghu_business_token", fetch: businessFetch }),
|
||||
cacheDbPath,
|
||||
});
|
||||
// Default online-if-uncached must not satisfy the switched token from the
|
||||
// prior token's fresh authoritative personal-endpoint cache.
|
||||
const { models } = await businessManager.refresh("online-if-uncached");
|
||||
expect(businessSeen).toContain("https://api.github.com/copilot_internal/user");
|
||||
expect(businessSeen).toContain("https://api.business.githubcopilot.com/models");
|
||||
expect(models.some(model => model.baseUrl === "https://api.business.githubcopilot.com")).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps structured OAuth keys for discovery and routes enterprise discovery to the enterprise host", async () => {
|
||||
@@ -355,7 +426,7 @@ describe("github copilot model limits mapping", () => {
|
||||
const { models } = await manager.refresh("online-if-uncached");
|
||||
const model = models.find(candidate => candidate.id === migration.id);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(model?.api).toBe("openai-responses");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
@@ -390,7 +461,7 @@ describe("github copilot model limits mapping", () => {
|
||||
});
|
||||
const { models } = await manager.refresh("online-if-uncached");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
// The bundled catalog now ships a responses-route grok-4.5, so the id
|
||||
// resurfaces from the bundle after the failed refresh. The migration
|
||||
// contract is that the stale cached COMPLETIONS route never comes
|
||||
|
||||
@@ -314,6 +314,34 @@ describe("model thinking derivation", () => {
|
||||
expect(getSupportedEfforts(v32)).toEqual([Effort.High, Effort.Max]);
|
||||
});
|
||||
|
||||
it("grants the low/high/max ladder to OpenRouter deepseek-v4-pro-0813 but not the undated route (issue #8517)", () => {
|
||||
// OpenRouter's /models advertises reasoning.supported_efforts
|
||||
// [low, high, max] for the dated SKU; the discovered ladder is baked
|
||||
// into thinking.efforts.
|
||||
const discovered = { mode: "effort" as const, efforts: [Effort.Low, Effort.High, Effort.Max] };
|
||||
const dated = createModel({
|
||||
id: "deepseek/deepseek-v4-pro-0813",
|
||||
api: "openrouter",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
thinking: discovered,
|
||||
});
|
||||
const bare = createModel({
|
||||
id: "deepseek/deepseek-v4-pro",
|
||||
api: "openrouter",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
thinking: discovered,
|
||||
});
|
||||
|
||||
// The dated SKU keeps its advertised ladder; :max no longer clamps.
|
||||
expect(getSupportedEfforts(dated)).toEqual([Effort.Low, Effort.High, Effort.Max]);
|
||||
expect(clampThinkingLevelForModel(dated, Effort.Max)).toBe(Effort.Max);
|
||||
// The undated OpenRouter route stays high-only.
|
||||
expect(getSupportedEfforts(bare)).toEqual([Effort.High]);
|
||||
expect(clampThinkingLevelForModel(bare, Effort.Max)).toBe(Effort.High);
|
||||
});
|
||||
|
||||
it("encodes the Gemini 3 Pro effort gap and mandatory reasoning in metadata", () => {
|
||||
const model = createModel({
|
||||
id: "gemini-3-pro-preview",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
const FIXTURE = `${import.meta.dir}/fixtures/models-lazy-provider-cache.ts`;
|
||||
|
||||
test("bundled models are enriched one provider at a time", () => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, FIXTURE],
|
||||
env: process.env,
|
||||
});
|
||||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||||
}, 60_000);
|
||||
@@ -814,6 +814,27 @@ describe("antigravity discovery collapsing", () => {
|
||||
supportsImages: true,
|
||||
thinkingBudget: 10_000,
|
||||
},
|
||||
"gemini-3.7-flash-low": {
|
||||
displayName: "Gemini 3.7 Flash Low",
|
||||
supportsThinking: true,
|
||||
supportsImages: true,
|
||||
maxTokens: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
},
|
||||
"gemini-3.7-flash-medium": {
|
||||
displayName: "Gemini 3.7 Flash Medium",
|
||||
supportsThinking: true,
|
||||
supportsImages: true,
|
||||
maxTokens: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
},
|
||||
"gemini-3.7-flash-high": {
|
||||
displayName: "Gemini 3.7 Flash High",
|
||||
supportsThinking: true,
|
||||
supportsImages: true,
|
||||
maxTokens: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
},
|
||||
"claude-sonnet-4-6": { displayName: "Claude Sonnet 4.6", supportsThinking: true, supportsImages: true },
|
||||
"claude-sonnet-4-6-thinking": {
|
||||
displayName: "Claude Sonnet 4.6 Thinking",
|
||||
@@ -835,7 +856,12 @@ describe("antigravity discovery collapsing", () => {
|
||||
it("returns collapsed logical entries and keeps the denylist", async () => {
|
||||
const models = await fetchAntigravityDiscoveryModels({ token: "t", endpoint: "https://cca.test", fetcher });
|
||||
|
||||
expect(models?.map(m => m.id).sort()).toEqual(["claude-sonnet-4-6", "gemini-2.5-flash", "gemini-3.5-flash"]);
|
||||
expect(models?.map(m => m.id).sort()).toEqual([
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3.7-flash",
|
||||
]);
|
||||
const flash = models?.find(m => m.id === "gemini-3.5-flash");
|
||||
expect(flash?.requestModelId).toBe("gemini-3.5-flash-extra-low");
|
||||
expect(flash?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-3-flash-agent");
|
||||
@@ -845,6 +871,19 @@ describe("antigravity discovery collapsing", () => {
|
||||
const flash25 = models?.find(m => m.id === "gemini-2.5-flash");
|
||||
expect(flash25?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-2.5-flash-thinking");
|
||||
expect(flash25?.thinking?.effortRouting?.off).toBe("gemini-2.5-flash");
|
||||
const flash37 = models?.find(m => m.id === "gemini-3.7-flash");
|
||||
expect(flash37?.requestModelId).toBe("gemini-3.7-flash-low");
|
||||
expect(flash37?.thinking).toEqual({
|
||||
mode: "google-level",
|
||||
efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High],
|
||||
requiresEffort: true,
|
||||
effortRouting: {
|
||||
minimal: "gemini-3.7-flash-low",
|
||||
low: "gemini-3.7-flash-low",
|
||||
medium: "gemini-3.7-flash-medium",
|
||||
high: "gemini-3.7-flash-high",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps collapsed routing through the gemini-cli re-provision", async () => {
|
||||
@@ -860,6 +899,10 @@ describe("antigravity discovery collapsing", () => {
|
||||
expect(flash?.baseUrl).toBe("https://cca.test");
|
||||
expect(flash?.requestModelId).toBe("gemini-3.5-flash-extra-low");
|
||||
expect(flash?.thinking?.effortRouting?.off).toBe("gemini-3.5-flash-extra-low");
|
||||
const flash37 = models?.find(m => m.id === "gemini-3.7-flash");
|
||||
expect(flash37?.requestModelId).toBe("gemini-3.7-flash-low");
|
||||
expect(flash37?.thinking?.requiresEffort).toBe(true);
|
||||
expect(flash37?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-3.7-flash-high");
|
||||
});
|
||||
|
||||
it("uses the primary daily endpoint by default", async () => {
|
||||
@@ -877,7 +920,9 @@ describe("antigravity discovery collapsing", () => {
|
||||
fetcher: defaultFetcher,
|
||||
});
|
||||
|
||||
expect(requestedUrls[0]).toContain(ANTIGRAVITY_PRIMARY_ENDPOINT);
|
||||
const discoveryUrl = requestedUrls.find(url => url.includes("/v1internal:fetchAvailableModels"));
|
||||
expect(discoveryUrl).toBeDefined();
|
||||
expect(discoveryUrl).toContain(ANTIGRAVITY_PRIMARY_ENDPOINT);
|
||||
expect(models?.[0]?.baseUrl).toBe(ANTIGRAVITY_PRIMARY_ENDPOINT);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,46 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed repeated `/btw` panels committing transient frames to native scrollback and replaying conversation history after dismissal.
|
||||
## [17.3.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced the MuPDF-WASM PDF document backend with `pdf-inspector` through `@oh-my-pi/pi-natives`, preserving cached text conversion and PDF line selectors while reporting pages that need OCR.
|
||||
- Restored `read <pdf>:` and `read <pdf>:<image>.png` page rendering by automatically capturing PDF pages through the headless Chromium browser tool.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Streamable HTTP MCP sessions being invalidated by opening the optional GET SSE stream before sending `notifications/initialized`, which prevented Figma Dev Mode MCP from connecting ([#8514](https://github.com/can1357/oh-my-pi/issues/8514)).
|
||||
- Fixed the `/hotkeys` table describing Ctrl+D (`app.exit`) as "Exit (when editor is empty)" when it actually exits unconditionally and saves the current prompt as a resumable draft ([#8530](https://github.com/can1357/oh-my-pi/issues/8530)).
|
||||
- Fixed Ctrl+G external editors failing to launch on Windows because Bun re-quoted the embedded `cmd.exe /c` command line ([#8544](https://github.com/can1357/oh-my-pi/issues/8544)).
|
||||
|
||||
## [17.3.3] - 2026-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Automatically continued Gemini turns that stopped after thinking without final output, using a bounded final-answer reminder instead of exhausting generic retries.
|
||||
- Retried Gemini `MALFORMED_FUNCTION_CALL` failures when every emitted tool call was proven unexecuted, while preserving real tool-result and visible-output replay guards.
|
||||
- Kept current terminal retry errors in one pinned banner with attempt context while surfacing local continuation failures instead of stale provider errors.
|
||||
|
||||
## [17.3.2] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the parent TUI stalling after a subagent submits its result until terminal focus or resize wakes the event loop ([#8462](https://github.com/can1357/oh-my-pi/issues/8462)).
|
||||
- Fixed `omp update` misclassifying foreign npm/bun bin aliases while preserving package-manager ownership for globally linked checkouts ([#8468](https://github.com/can1357/oh-my-pi/issues/8468)).
|
||||
- Fixed `read` hashline headers collapsing nested in-workspace paths to the bare basename, which let a same-basename file at the session cwd capture a verbatim follow-up `edit` and deterministically reject it with `hash is not from this session`. Headers now retain the workspace-relative path (e.g. `[src/settings.json#0063]`) ([#8482](https://github.com/can1357/oh-my-pi/issues/8482)).
|
||||
|
||||
## [17.3.1] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude Code user discovery ignoring CLAUDE_CONFIG_DIR for configuration, plugins, MCP servers, and imported sessions.
|
||||
- Fixed the status-line git branch display freezing after switching branches.
|
||||
- Fixed Pi extension contexts omitting the runtime mode, which caused TUI guards to silently disable extension UI.
|
||||
- Fixed extension-registered tool names being rejected by the --tools flag before extension discovery, which prevented least-privilege sessions from allowlisting plugin tools.
|
||||
- Fixed omp plugin install failing with cloning errors for legacy Pi extensions whose tool schemas use legacy-typebox builders.
|
||||
- Fixed omp update aborting with chmod ENOENT when concurrent update runs overlapped by using unique download temporary paths.
|
||||
- Fixed the browser tool executable probe launching the user's installed GUI Chromium on Windows: the `--version` version probe from ecb22957 was Linux-scoped but ran for every platform candidate, so on Windows it could hand off to a running `chrome.exe`, open a normal browser window, then reject the candidate and fall back to cached Chrome for Testing. The probe is now confined to Linux ([#8445](https://github.com/can1357/oh-my-pi/issues/8445)).
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "17.3.0",
|
||||
"version": "17.3.4",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
@@ -41,8 +41,6 @@
|
||||
"format-prompts": "bun scripts/format-prompts.ts",
|
||||
"gen:tool-views": "bun --cwd=../collab-web run gen:tool-views",
|
||||
"gen:bundle": "bun scripts/bundle-dist.ts",
|
||||
"gen:mupdf": "bun scripts/embed-mupdf-wasm.ts --generate",
|
||||
"gen:mupdf:reset": "bun scripts/embed-mupdf-wasm.ts --reset",
|
||||
"gen:native": "bun --cwd=../natives run gen:native",
|
||||
"gen:native:reset": "bun --cwd=../natives run gen:native:reset",
|
||||
"prepack": "bun run gen:tool-views && bun run gen:bundle",
|
||||
@@ -73,7 +71,6 @@
|
||||
"@opentelemetry/sdk-metrics": "catalog:",
|
||||
"@opentelemetry/sdk-trace-base": "catalog:",
|
||||
"@opentelemetry/sdk-trace-node": "catalog:",
|
||||
"mupdf": "catalog:",
|
||||
"puppeteer-core": "catalog:"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -88,7 +88,6 @@ async function main(): Promise<void> {
|
||||
["bun", "--cwd=../natives", "run", "gen:native"],
|
||||
crossBuild ? { ...Bun.env, TARGET_PLATFORM: crossBuild.platform, TARGET_ARCH: crossBuild.arch } : Bun.env,
|
||||
);
|
||||
await runCommand(["bun", "run", "gen:mupdf"]);
|
||||
try {
|
||||
await compileCodingAgent({
|
||||
repoRoot,
|
||||
@@ -104,7 +103,6 @@ async function main(): Promise<void> {
|
||||
await runCommand(["codesign", "--force", "--sign", "-", outputPath]);
|
||||
}
|
||||
} finally {
|
||||
await runCommand(["bun", "run", "gen:mupdf:reset"]);
|
||||
await runCommand(["bun", "--cwd=../natives", "run", "gen:native:reset"]);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -15,7 +15,6 @@ const legacyHtmlExportAssetPattern = /^(?:template-[^.]+\.(?:css|html|js)|tool-v
|
||||
// `omp-legacy-pi-modules` exists only in compiled binaries via the build plugin;
|
||||
// the npm bundle never executes that `isCompiledBinary()` branch.
|
||||
const ALWAYS_EXTERNAL = [
|
||||
"mupdf",
|
||||
"@oh-my-pi/pi-natives",
|
||||
"@huggingface/transformers",
|
||||
"fastembed",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
// Embeds mupdf's `mupdf-wasm.wasm` into the compiled single-file binary.
|
||||
//
|
||||
// mupdf loads its wasm by reading the `mupdf-wasm.wasm` sibling of its own
|
||||
// module via `new URL(..., import.meta.url)` + `readFileSync`. A `bun --compile`
|
||||
// binary has no node_modules, so that read fails (`ENOENT .../mupdf-wasm.wasm`),
|
||||
// and marking mupdf `--external` instead makes `bun --compile` eagerly fail to
|
||||
// resolve the package at startup (the static `import * as mupdf` lives in a lazy
|
||||
// chunk but is hoisted). So the binary build bundles mupdf and embeds the wasm
|
||||
// bytes here, handing them to the WASM module as `$libmupdf_wasm_Module.wasmBinary`
|
||||
// (see src/utils/markit.ts).
|
||||
//
|
||||
// `--generate` copies the wasm next to src/utils/mupdf-wasm-embed.ts and rewrites
|
||||
// that module to import it via `with { type: "file" }`; `--reset` restores the
|
||||
// checked-in placeholder and removes the copy. The npm `dist/cli.js` bundle never
|
||||
// runs this — it keeps mupdf external and loads the wasm from node_modules.
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
const utilsDir = path.join(import.meta.dir, "..", "src", "utils");
|
||||
const helperPath = path.join(utilsDir, "mupdf-wasm-embed.ts");
|
||||
const wasmCopyPath = path.join(utilsDir, "mupdf-wasm.wasm");
|
||||
|
||||
const placeholder = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand.
|
||||
//
|
||||
// Compiled single-file binaries cannot let mupdf resolve its \`mupdf-wasm.wasm\`
|
||||
// sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts
|
||||
// and scripts/ci-release-build-binaries.ts) regenerates this module to embed the
|
||||
// wasm bytes via \`with { type: "file" }\` and copies the wasm next to it. Source
|
||||
// checkouts, \`bun test\`, and the npm \`dist/cli.js\` bundle keep mupdf external and
|
||||
// load the wasm from node_modules, so this placeholder returns undefined and the
|
||||
// build resets back to it afterward.
|
||||
export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
|
||||
\treturn undefined;
|
||||
}
|
||||
`;
|
||||
|
||||
const generated = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit or commit.
|
||||
import { readFileSync } from "node:fs";
|
||||
import wasmPath from "./mupdf-wasm.wasm" with { type: "file" };
|
||||
|
||||
export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
|
||||
\treturn readFileSync(wasmPath);
|
||||
}
|
||||
`;
|
||||
|
||||
if (process.argv.includes("--reset")) {
|
||||
await Bun.write(helperPath, placeholder);
|
||||
try {
|
||||
await fs.unlink(wasmCopyPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const wasmSource = path.join(path.dirname(createRequire(import.meta.url).resolve("mupdf")), "mupdf-wasm.wasm");
|
||||
const wasmFile = Bun.file(wasmSource);
|
||||
if (!(await wasmFile.exists())) {
|
||||
throw new Error(`mupdf wasm not found at ${wasmSource}; run \`bun install\` first.`);
|
||||
}
|
||||
await Bun.write(wasmCopyPath, wasmFile);
|
||||
await Bun.write(helperPath, generated);
|
||||
console.log(`Embedded mupdf wasm (${wasmFile.size} bytes) into ${path.relative(process.cwd(), wasmCopyPath)}`);
|
||||
@@ -152,6 +152,7 @@ export class AsyncJobManager {
|
||||
readonly #maxRunningJobs: number;
|
||||
readonly #retentionMs: number;
|
||||
#deliveryLoop: Promise<void> | undefined;
|
||||
#deliveryQueueChanged = Promise.withResolvers<void>();
|
||||
#disposed = false;
|
||||
|
||||
#filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
|
||||
@@ -335,6 +336,7 @@ export class AsyncJobManager {
|
||||
for (const jobId of uniqueJobIds) {
|
||||
this.#watchedJobs.add(jobId);
|
||||
}
|
||||
this.#notifyDeliveryQueueChanged();
|
||||
return uniqueJobIds.length;
|
||||
}
|
||||
|
||||
@@ -389,6 +391,7 @@ export class AsyncJobManager {
|
||||
this.#deliveries.length,
|
||||
...this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId)),
|
||||
);
|
||||
this.#notifyDeliveryQueueChanged();
|
||||
return before - this.#deliveries.length;
|
||||
}
|
||||
|
||||
@@ -603,6 +606,7 @@ export class AsyncJobManager {
|
||||
this.#clearEvictionTimers();
|
||||
this.#jobs.clear();
|
||||
this.#deliveries.length = 0;
|
||||
this.#notifyDeliveryQueueChanged();
|
||||
this.#inFlightDeliveries.length = 0;
|
||||
this.#suppressedDeliveries.clear();
|
||||
this.#watchedJobs.clear();
|
||||
@@ -704,13 +708,14 @@ export class AsyncJobManager {
|
||||
const now = Date.now();
|
||||
if (selected.nextAttemptAt > now) {
|
||||
if (selected.nextAttemptAt > deadline) return false;
|
||||
await Bun.sleep(selected.nextAttemptAt - now);
|
||||
await this.#waitForDeliveryQueueChange(selected.nextAttemptAt - now);
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = this.#deliveries.indexOf(selected);
|
||||
if (index === -1) continue;
|
||||
this.#deliveries.splice(index, 1);
|
||||
this.#notifyDeliveryQueueChanged();
|
||||
if (this.isDeliverySuppressed(selected.jobId)) continue;
|
||||
|
||||
return this.#waitForDeliveryPromise(this.#deliverDelivery(selected), deadline);
|
||||
@@ -726,7 +731,7 @@ export class AsyncJobManager {
|
||||
if (this.isDeliverySuppressed(jobId)) {
|
||||
return;
|
||||
}
|
||||
this.#deliveries.push({
|
||||
this.#queueDelivery({
|
||||
jobId,
|
||||
text,
|
||||
attempt: 0,
|
||||
@@ -762,7 +767,8 @@ export class AsyncJobManager {
|
||||
}
|
||||
const waitMs = delivery.nextAttemptAt - Date.now();
|
||||
if (waitMs > 0) {
|
||||
await Bun.sleep(waitMs);
|
||||
await this.#waitForDeliveryQueueChange(waitMs);
|
||||
continue;
|
||||
}
|
||||
if (this.#deliveries[0] !== delivery) {
|
||||
continue;
|
||||
@@ -813,7 +819,7 @@ export class AsyncJobManager {
|
||||
delivery.lastError = error instanceof Error ? error.message : String(error);
|
||||
delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt);
|
||||
if (!this.isDeliverySuppressed(delivery.jobId)) {
|
||||
this.#deliveries.push(delivery);
|
||||
this.#queueDelivery(delivery);
|
||||
}
|
||||
logger.warn("Async job completion delivery failed", {
|
||||
jobId: delivery.jobId,
|
||||
@@ -831,6 +837,29 @@ export class AsyncJobManager {
|
||||
return promise;
|
||||
}
|
||||
|
||||
#queueDelivery(delivery: AsyncJobDelivery): void {
|
||||
const index = this.#deliveries.findIndex(candidate => candidate.nextAttemptAt > delivery.nextAttemptAt);
|
||||
if (index === -1) this.#deliveries.push(delivery);
|
||||
else this.#deliveries.splice(index, 0, delivery);
|
||||
this.#notifyDeliveryQueueChanged();
|
||||
}
|
||||
|
||||
async #waitForDeliveryQueueChange(delayMs: number): Promise<void> {
|
||||
const timerElapsed = Promise.withResolvers<void>();
|
||||
const timer = setTimeout(timerElapsed.resolve, delayMs);
|
||||
timer.unref();
|
||||
try {
|
||||
await Promise.race([timerElapsed.promise, this.#deliveryQueueChanged.promise]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
#notifyDeliveryQueueChanged(): void {
|
||||
this.#deliveryQueueChanged.resolve();
|
||||
this.#deliveryQueueChanged = Promise.withResolvers<void>();
|
||||
}
|
||||
|
||||
async #waitForDeliveryPromise(promise: Promise<void> | undefined, deadline: number): Promise<boolean> {
|
||||
if (!promise) return true;
|
||||
if (deadline === Number.POSITIVE_INFINITY) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { $env, APP_NAME, logger } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "@oh-my-pi/pi-utils/chalk";
|
||||
import type { ServiceTierOpenAISettingValue } from "../config/service-tier";
|
||||
import { CLI_THINKING_LEVELS, type ConfiguredThinkingLevel, parseCliThinkingLevel } from "../thinking";
|
||||
import { BUILTIN_TOOL_NAMES, HIDDEN_TOOL_NAMES, normalizeToolNames } from "../tools/builtin-names";
|
||||
import { normalizeToolNames } from "../tools/builtin-names";
|
||||
import {
|
||||
OPTIONAL_FLAGS,
|
||||
OPTIONAL_VALUE_FLAGS,
|
||||
@@ -108,7 +108,6 @@ export interface Args {
|
||||
const PARSE_DEPS: ParseDeps = {
|
||||
logger,
|
||||
parseThinking: parseCliThinkingLevel,
|
||||
builtinToolNames: [...BUILTIN_TOOL_NAMES, ...HIDDEN_TOOL_NAMES],
|
||||
normalizeToolNames,
|
||||
thinkingEfforts: CLI_THINKING_LEVELS,
|
||||
};
|
||||
@@ -148,6 +147,7 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
// reparse in `runRootCommand` parses it a second time). Mutating the input
|
||||
// would corrupt that later parse, so never touch the caller's array.
|
||||
const args = [...inputArgs];
|
||||
const parseDeps = PARSE_DEPS;
|
||||
const result: Args = {
|
||||
messages: [],
|
||||
fileArgs: [],
|
||||
@@ -214,7 +214,7 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
if (i + 1 < args.length && args[i + 1] !== PROFILE_BOOTSTRAP_BOUNDARY_ARG) {
|
||||
const consumed = consumeBuiltInStringValue(arg, args, i + 1);
|
||||
i = consumed.index;
|
||||
STRING_SETTERS[arg](result, consumed.value, PARSE_DEPS);
|
||||
STRING_SETTERS[arg](result, consumed.value, parseDeps);
|
||||
}
|
||||
} else if (OPTIONAL_VALUE_FLAGS.has(arg)) {
|
||||
const config = OPTIONAL_FLAGS[arg];
|
||||
@@ -332,6 +332,17 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Reject requested tool names absent from the fully discovered session registry. */
|
||||
export function validateToolNames(requested: readonly string[] | undefined, known: readonly string[]): void {
|
||||
if (!requested) return;
|
||||
const knownNames = new Set(known);
|
||||
const unknown = requested.filter(name => !knownNames.has(name));
|
||||
if (unknown.length === 0) return;
|
||||
throw new CliUsageError(
|
||||
`Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${known.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a stderr error listing the unrecognized flags and return `true` when
|
||||
* there were any. Caller is expected to exit with a non-zero status. Splitting
|
||||
|
||||
@@ -29,18 +29,14 @@ export interface ExtensionFlagSink {
|
||||
* semantics and surfaces in `unknownFlags` — without consuming the following
|
||||
* message or overwriting the built-in field. No built-in name list to maintain.
|
||||
*
|
||||
* Returns `null` when there is no sink or no registered extension flags, in
|
||||
* which case the caller keeps its original startup parse (an extension-aware
|
||||
* re-parse would be identical anyway).
|
||||
* Returns `null` only when there is no sink. Once extensions have loaded, the
|
||||
* reparse always runs so `--tools` can be validated against their registered
|
||||
* tools even when no extension registered CLI flags.
|
||||
*/
|
||||
export function applyExtensionFlags(runner: ExtensionFlagSink | undefined, rawArgs: string[]): Args | null {
|
||||
const extensionFlags = runner?.getFlags();
|
||||
if (!runner || !extensionFlags || extensionFlags.size === 0) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseArgs(rawArgs, extensionFlags);
|
||||
// `parseArgs` only records registered extension flags in `unknownFlags`, so
|
||||
// every entry here is a flag this runner owns that was actually passed.
|
||||
if (!runner) return null;
|
||||
const parsed = parseArgs(rawArgs, runner.getFlags());
|
||||
// `parseArgs` records extension flag values in `unknownFlags`.
|
||||
for (const [name, value] of parsed.unknownFlags) {
|
||||
runner.setFlagValue(name, value);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import { CliUsageError } from "./usage-error";
|
||||
export interface ParseDeps {
|
||||
logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
|
||||
parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined;
|
||||
builtinToolNames: readonly string[];
|
||||
normalizeToolNames: (values: Iterable<string>) => string[];
|
||||
thinkingEfforts: readonly string[];
|
||||
}
|
||||
@@ -189,15 +188,8 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
// An unknown name silently narrowing the toolset is worse than a failed
|
||||
// launch: scripts keep running believing the tool is available (e.g. a
|
||||
// stale `--tools bash,ssh` after the ssh tool's removal).
|
||||
const unknown = names.filter(name => !deps.builtinToolNames.includes(name));
|
||||
if (unknown.length > 0) {
|
||||
throw new CliUsageError(
|
||||
`Unknown tool${unknown.length === 1 ? "" : "s"} in --tools: ${unknown.join(", ")}. Valid tools: ${deps.builtinToolNames.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
// Validation runs after session tool discovery. At this point extension,
|
||||
// custom, plugin-manifest, and MCP tools are not all known yet.
|
||||
result.tools = names;
|
||||
},
|
||||
"--thinking": (result, value, deps) => {
|
||||
|
||||
@@ -151,11 +151,21 @@ function numberSetting(value: number | undefined, fallback: unknown, defaultValu
|
||||
async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
|
||||
const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
|
||||
const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
|
||||
const archiveSelected = selected && flags.archive === true;
|
||||
const needsArchiveSettings =
|
||||
archiveSelected &&
|
||||
(flags.coldArchiveAfterDays === undefined ||
|
||||
flags.retainNewestGlobal === undefined ||
|
||||
flags.retainNewestPerCwd === undefined);
|
||||
const settings =
|
||||
flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir });
|
||||
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
|
||||
!selected || needsArchiveSettings
|
||||
? flags.apply === true
|
||||
? await Settings.loadIsolated({ agentDir })
|
||||
: await Settings.loadReadOnly({ agentDir })
|
||||
: undefined;
|
||||
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings?.get(pathKey) ?? getDefault(pathKey);
|
||||
const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
|
||||
settings.get(pathKey);
|
||||
settings?.get(pathKey) ?? getDefault(pathKey);
|
||||
return {
|
||||
apply: flags.apply === true,
|
||||
json: flags.json === true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import chalk from "@oh-my-pi/pi-utils/chalk";
|
||||
import { Settings } from "../config/settings";
|
||||
import { extractUriScheme } from "../internal-urls/parse";
|
||||
import { InternalUrlRouter } from "../internal-urls/router";
|
||||
import { closeDaemonClients } from "../launch/client";
|
||||
import { discoverAndLoadMCPTools } from "../mcp/loader";
|
||||
import { MCPManager } from "../mcp/manager";
|
||||
import { discoverAuthStorage } from "../session/auth-broker-config";
|
||||
@@ -93,6 +94,7 @@ export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
|
||||
if (MCPManager.instance() === mcpManager) MCPManager.setInstance(undefined);
|
||||
}
|
||||
authStorage?.close();
|
||||
await closeDaemonClients();
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
|
||||
@@ -66,7 +66,7 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
interface PythonCheckResult {
|
||||
export interface PythonCheckResult {
|
||||
available: boolean;
|
||||
pythonPath?: string;
|
||||
usingManagedEnv?: boolean;
|
||||
@@ -82,7 +82,7 @@ function managedPythonPath(): string {
|
||||
/**
|
||||
* Check Python environment and kernel dependencies.
|
||||
*/
|
||||
async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
|
||||
export async function checkPythonSetup(cwd: string, interpreter?: string): Promise<PythonCheckResult> {
|
||||
const availability = await checkPythonKernelAvailability(cwd, interpreter, { forceProbe: true });
|
||||
return {
|
||||
available: availability.ok,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { $env, $which, APP_NAME, compareVersions, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "@oh-my-pi/pi-utils/chalk";
|
||||
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
|
||||
import { $ } from "bun";
|
||||
import { theme } from "../modes/theme/theme";
|
||||
import { isTimeoutError, withTimeoutSignal } from "../utils/fetch-timeout";
|
||||
@@ -466,6 +467,27 @@ function isPathInDirectory(filePath: string, directoryPath: string): boolean {
|
||||
return isPathInDirectoryLexical(resolvedFile, dirReal);
|
||||
}
|
||||
|
||||
function isPathInManagerRoot(linkTarget: string, nodeModulesDir: string): boolean {
|
||||
if (isPathInDirectoryLexical(linkTarget, nodeModulesDir)) return true;
|
||||
// Resolve only the manager root. Resolving the link target itself would
|
||||
// follow globally linked packages into their checkout and lose ownership.
|
||||
const nodeModulesReal = tryRealpath(path.resolve(nodeModulesDir));
|
||||
return nodeModulesReal !== undefined && isPathInDirectoryLexical(linkTarget, nodeModulesReal);
|
||||
}
|
||||
|
||||
function resolveNpmGlobalNodeModulesDir(globalBinDir: string | undefined): string | undefined {
|
||||
if (!globalBinDir) return undefined;
|
||||
if (process.platform === "win32") return path.join(globalBinDir, "node_modules");
|
||||
return path.join(path.dirname(globalBinDir), "lib", "node_modules");
|
||||
}
|
||||
|
||||
function isManagerOwnedBinEntry(linkTarget: string | undefined, nodeModulesDir: string | undefined): boolean {
|
||||
// Non-symlink launchers and unreadable links retain the existing bin-dir
|
||||
// classification. A readable link must point through the manager's exact
|
||||
// global node_modules tree.
|
||||
return linkTarget === undefined || (nodeModulesDir !== undefined && isPathInManagerRoot(linkTarget, nodeModulesDir));
|
||||
}
|
||||
|
||||
type UpdateMethod = "brew" | "mise" | "nix" | "bun" | "npm" | "binary";
|
||||
|
||||
interface UpdateMethodResolutionOptions {
|
||||
@@ -473,6 +495,8 @@ interface UpdateMethodResolutionOptions {
|
||||
miseBinDirs?: readonly string[];
|
||||
miseDataDir?: string;
|
||||
npmBinDir?: string;
|
||||
/** Bun's configured global package directory, independent of its bin directory. */
|
||||
bunGlobalDir?: string;
|
||||
/**
|
||||
* Whether the resolved omp path is a plain file (the standalone binary)
|
||||
* rather than a package-manager symlink. Stops a binary install from being
|
||||
@@ -480,6 +504,11 @@ interface UpdateMethodResolutionOptions {
|
||||
* target directory.
|
||||
*/
|
||||
ompIsRegularFile?: boolean;
|
||||
/**
|
||||
* Absolute path named by the bin entry's first symlink hop. This deliberately
|
||||
* preserves a global package symlink instead of resolving into its checkout.
|
||||
*/
|
||||
ompLinkTarget?: string;
|
||||
}
|
||||
|
||||
type UpdateTarget =
|
||||
@@ -495,7 +524,15 @@ function resolveUpdateMethod(
|
||||
bunBinDir: string | undefined,
|
||||
options: UpdateMethodResolutionOptions = {},
|
||||
): UpdateMethod {
|
||||
const { homebrewPrefix, miseBinDirs = [], miseDataDir, npmBinDir, ompIsRegularFile = false } = options;
|
||||
const {
|
||||
bunGlobalDir,
|
||||
homebrewPrefix,
|
||||
miseBinDirs = [],
|
||||
miseDataDir,
|
||||
npmBinDir,
|
||||
ompIsRegularFile = false,
|
||||
ompLinkTarget,
|
||||
} = options;
|
||||
const launcherExtension = path.extname(ompPath).toLowerCase();
|
||||
const isWindowsScriptLauncher =
|
||||
launcherExtension === ".cmd" || launcherExtension === ".ps1" || launcherExtension === ".bat";
|
||||
@@ -513,9 +550,28 @@ function resolveUpdateMethod(
|
||||
// (bun's .exe launcher, npm's .cmd/.ps1), so a regular file is NOT evidence
|
||||
// of a standalone install and the override would hijack managed installs.
|
||||
const isStandaloneRegularFile = ompIsRegularFile && process.platform !== "win32";
|
||||
if (bunBinDir && isPathInDirectory(ompPath, bunBinDir) && !isStandaloneRegularFile) return "bun";
|
||||
if ((npmBinDir && isPathInDirectory(ompPath, npmBinDir) && !isStandaloneRegularFile) || isWindowsScriptLauncher)
|
||||
const bunNodeModulesDir = resolveBunGlobalNodeModulesDirFromLocations({
|
||||
globalDir: bunGlobalDir,
|
||||
globalBinDir: bunBinDir,
|
||||
});
|
||||
if (
|
||||
bunBinDir &&
|
||||
isPathInDirectory(ompPath, bunBinDir) &&
|
||||
!isStandaloneRegularFile &&
|
||||
isManagerOwnedBinEntry(ompLinkTarget, bunNodeModulesDir)
|
||||
) {
|
||||
return "bun";
|
||||
}
|
||||
const npmNodeModulesDir = resolveNpmGlobalNodeModulesDir(npmBinDir);
|
||||
if (
|
||||
npmBinDir &&
|
||||
isPathInDirectory(ompPath, npmBinDir) &&
|
||||
!isStandaloneRegularFile &&
|
||||
isManagerOwnedBinEntry(ompLinkTarget, npmNodeModulesDir)
|
||||
) {
|
||||
return "npm";
|
||||
}
|
||||
if (isWindowsScriptLauncher) return "npm";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
@@ -526,6 +582,44 @@ export function resolveUpdateMethodForTest(
|
||||
): UpdateMethod {
|
||||
return resolveUpdateMethod(ompPath, bunBinDir, options);
|
||||
}
|
||||
|
||||
/** Resolve an update target from the concrete PATH entry selected by the shell. */
|
||||
export function resolveUpdateTargetFromPath(
|
||||
ompPath: string,
|
||||
bunBinDir: string | undefined,
|
||||
options: UpdateMethodResolutionOptions & { allowPackageManagers: boolean },
|
||||
): UpdateTarget {
|
||||
let ompIsRegularFile = false;
|
||||
let ompIsSymlink = false;
|
||||
let ompLinkTarget: string | undefined;
|
||||
let ompRealpath: string | undefined;
|
||||
try {
|
||||
const stat = fs.lstatSync(ompPath);
|
||||
ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
|
||||
ompIsSymlink = stat.isSymbolicLink();
|
||||
if (ompIsSymlink) {
|
||||
const rawTarget = fs.readlinkSync(ompPath);
|
||||
const linkDir = path.dirname(ompPath);
|
||||
ompLinkTarget = path.resolve(tryRealpath(linkDir) ?? linkDir, rawTarget);
|
||||
ompRealpath = tryRealpath(ompPath);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const method = resolveUpdateMethod(ompPath, bunBinDir, {
|
||||
...options,
|
||||
ompIsRegularFile,
|
||||
ompLinkTarget,
|
||||
});
|
||||
if (method === "binary") {
|
||||
// A package-manager-enabled update follows a foreign alias to replace
|
||||
// its standalone binary. Binary-only releases intentionally replace the
|
||||
// selected manager launcher in place.
|
||||
const binaryPath = options.allowPackageManagers && ompIsSymlink ? (ompRealpath ?? ompPath) : ompPath;
|
||||
return { method, path: binaryPath, replacesSymlink: ompIsSymlink && binaryPath === ompPath };
|
||||
}
|
||||
if (method === "bun" || method === "npm") return { method, path: ompPath };
|
||||
return { method };
|
||||
}
|
||||
/**
|
||||
* Resolve how the running install should be updated.
|
||||
*
|
||||
@@ -545,27 +639,14 @@ async function resolveUpdateTarget(options: { allowPackageManagers: boolean }):
|
||||
const ompPath = resolveOmpPath();
|
||||
|
||||
if (ompPath) {
|
||||
// Package-manager installs symlink the bin entry into node_modules; the
|
||||
// standalone installer writes a plain executable. When the global bin dir
|
||||
// overlaps the installer's default (~/.local/bin), that file type — not
|
||||
// directory containment — distinguishes a binary install from npm/bun.
|
||||
let ompIsRegularFile = false;
|
||||
let ompIsSymlink = false;
|
||||
try {
|
||||
const stat = fs.lstatSync(ompPath);
|
||||
ompIsRegularFile = stat.isFile() && !stat.isSymbolicLink();
|
||||
ompIsSymlink = stat.isSymbolicLink();
|
||||
} catch {}
|
||||
const method = resolveUpdateMethod(ompPath, bunBinDir, {
|
||||
return resolveUpdateTargetFromPath(ompPath, bunBinDir, {
|
||||
allowPackageManagers: options.allowPackageManagers,
|
||||
bunGlobalDir: options.allowPackageManagers ? process.env.BUN_INSTALL_GLOBAL_DIR : undefined,
|
||||
homebrewPrefix,
|
||||
miseBinDirs,
|
||||
miseDataDir,
|
||||
npmBinDir,
|
||||
ompIsRegularFile,
|
||||
});
|
||||
if (method === "binary") return { method, path: ompPath, replacesSymlink: ompIsSymlink };
|
||||
if (method === "bun" || method === "npm") return { method, path: ompPath };
|
||||
return { method };
|
||||
}
|
||||
|
||||
if (bunBinDir) return { method: "bun" };
|
||||
@@ -794,10 +875,19 @@ async function resolveBunInstallCacheDir(): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBunGlobalNodeModulesDirFromLocations(
|
||||
globalBinDir: string | undefined,
|
||||
cacheDir: string | undefined,
|
||||
): string | undefined {
|
||||
interface BunGlobalInstallLocations {
|
||||
globalDir?: string;
|
||||
globalBinDir?: string;
|
||||
cacheDir?: string;
|
||||
}
|
||||
|
||||
/** Resolve Bun's global node_modules root from explicit, default, or cache locations. */
|
||||
export function resolveBunGlobalNodeModulesDirFromLocations({
|
||||
globalDir,
|
||||
globalBinDir,
|
||||
cacheDir,
|
||||
}: BunGlobalInstallLocations): string | undefined {
|
||||
if (globalDir && globalDir.length > 0) return path.join(globalDir, "node_modules");
|
||||
if (globalBinDir && globalBinDir.length > 0) {
|
||||
return path.join(path.dirname(globalBinDir), "install", "global", "node_modules");
|
||||
}
|
||||
@@ -811,9 +901,16 @@ async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise<string
|
||||
try {
|
||||
const result = await $`bun pm bin -g`.quiet().nothrow();
|
||||
const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined;
|
||||
return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir);
|
||||
return resolveBunGlobalNodeModulesDirFromLocations({
|
||||
globalDir: process.env.BUN_INSTALL_GLOBAL_DIR,
|
||||
globalBinDir,
|
||||
cacheDir,
|
||||
});
|
||||
} catch {
|
||||
return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir);
|
||||
return resolveBunGlobalNodeModulesDirFromLocations({
|
||||
globalDir: process.env.BUN_INSTALL_GLOBAL_DIR,
|
||||
cacheDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,8 +1084,8 @@ async function unlinkIfExists(filePath: string): Promise<void> {
|
||||
* running process image, so unlinking it fails with EPERM/EACCES until this
|
||||
* process exits (issue #845). The replacement and verification already
|
||||
* succeeded by the time we get here, so every error is swallowed; the leftover
|
||||
* is reclaimed by {@link sweepStaleBackups} on the next update once it is no
|
||||
* longer in use. Returns whether the file is gone.
|
||||
* is reclaimed by {@link sweepStaleUpdateArtifacts} on the next update once it
|
||||
* is no longer in use. Returns whether the file is gone.
|
||||
*/
|
||||
async function removeBackupBestEffort(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -1000,16 +1097,21 @@ async function removeBackupBestEffort(filePath: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort removal of binary-update backups left by earlier runs.
|
||||
* Best-effort removal of binary-update leftovers from earlier runs.
|
||||
*
|
||||
* Each self-update moves the previous executable to `<binary>.<timestamp>.<pid>.bak`
|
||||
* before swapping the new one in. On Windows that backup cannot be deleted
|
||||
* while the updating process is alive, so it is left for a later run to reclaim
|
||||
* once its owning process has exited. Also matches the legacy fixed
|
||||
* `<binary>.bak` name produced before backups were timestamped, so users
|
||||
* upgrading from a buggy release get the orphaned file cleaned up.
|
||||
* Each self-update writes to `<binary>.<timestamp>.<pid>.new` and moves the
|
||||
* previous executable to `<binary>.<timestamp>.<pid>.bak` before swapping the
|
||||
* new one in. On Windows a backup cannot be deleted while the updating process
|
||||
* is alive (it is the running process image), so it is left for a later run to
|
||||
* reclaim once its owning process has exited. A `.new` temp file only survives
|
||||
* a hard kill mid-download; it is reaped once older than the download window,
|
||||
* which a live download cannot exceed without timing out and cleaning up after
|
||||
* itself — so a concurrent run's in-progress temp is never deleted. Legacy
|
||||
* fixed `<binary>.bak` / `<binary>.new` names (from before suffixes were made
|
||||
* unique) are matched too, so users upgrading from a buggy release get the
|
||||
* orphaned files cleaned up.
|
||||
*/
|
||||
export async function sweepStaleBackups(targetPath: string): Promise<void> {
|
||||
export async function sweepStaleUpdateArtifacts(targetPath: string): Promise<void> {
|
||||
const dir = path.dirname(targetPath);
|
||||
const base = path.basename(targetPath);
|
||||
let entries: string[];
|
||||
@@ -1018,13 +1120,28 @@ export async function sweepStaleBackups(targetPath: string): Promise<void> {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith(`${base}.`) || !entry.endsWith(".bak")) continue;
|
||||
// Legacy "<base>.bak" → empty middle; new "<base>.<timestamp>.<pid>.bak"
|
||||
// → dot-separated numeric run. Anything else is an unrelated *.bak file.
|
||||
const middle = entry.slice(base.length + 1, entry.length - ".bak".length);
|
||||
if (!entry.startsWith(`${base}.`)) continue;
|
||||
const suffix = entry.endsWith(".bak") ? ".bak" : entry.endsWith(".new") ? ".new" : undefined;
|
||||
if (!suffix) continue;
|
||||
// Legacy "<base><suffix>" → empty middle; new "<base>.<timestamp>.<pid><suffix>"
|
||||
// → dot-separated numeric run. Anything else is an unrelated file.
|
||||
const middle = entry.slice(base.length + 1, entry.length - suffix.length);
|
||||
if (middle.length > 0 && !/^\d+(\.\d+)*$/.test(middle)) continue;
|
||||
await removeBackupBestEffort(path.join(dir, entry));
|
||||
const full = path.join(dir, entry);
|
||||
if (suffix === ".new") {
|
||||
// A temp file may belong to a concurrent update still downloading, so
|
||||
// only reap ones older than the download window.
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = (await fs.promises.stat(full)).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (now - mtimeMs < BINARY_DOWNLOAD_TIMEOUT_MS) continue;
|
||||
}
|
||||
await removeBackupBestEffort(full);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1334,6 +1451,11 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
|
||||
await printVerification(expectedVersion);
|
||||
}
|
||||
|
||||
// Monotonic within this process so two updates started in the same millisecond
|
||||
// (same pid, same `Date.now()`) still get distinct temp/backup paths. Kept
|
||||
// numeric so the artifact sweep's `\d+(\.\d+)*` matcher still reclaims them.
|
||||
let updateAttemptSeq = 0;
|
||||
|
||||
/**
|
||||
* Download a release binary to a target path, replacing an existing file.
|
||||
*/
|
||||
@@ -1348,12 +1470,18 @@ export async function updateViaBinaryAt(
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const binaryName = options.binaryName ?? getBinaryName();
|
||||
const tempPath = `${targetPath}.new`;
|
||||
// Unique per attempt: a stale backup from an earlier update may still be
|
||||
// locked (it is the previous process image on Windows), and a fixed name
|
||||
// would force the move-aside rename to overwrite it. pid + timestamp keeps
|
||||
// two forced updates in the same millisecond from colliding.
|
||||
const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
|
||||
// Unique per attempt so two overlapping `omp update` runs never share a temp
|
||||
// or backup path. A fixed temp name (`<binary>.new`) let the second run's
|
||||
// pre-download unlink delete the first run's still-downloading temp file; the
|
||||
// first kept writing to its open fd (size + digest still passed), then chmod
|
||||
// hit the missing path and the update aborted (issue #8434). The backup needs
|
||||
// the same uniqueness: a stale backup from an earlier update may still be
|
||||
// locked (the previous process image on Windows), so a fixed name would force
|
||||
// the move-aside rename to overwrite it. pid, timestamp, and a process-local
|
||||
// counter keep two updates started in the same millisecond from colliding.
|
||||
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
|
||||
const tempPath = `${targetPath}.${attempt}.new`;
|
||||
const backupPath = `${targetPath}.${attempt}.bak`;
|
||||
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
|
||||
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
||||
await downloadVerifiedBinary({
|
||||
@@ -1365,16 +1493,22 @@ export async function updateViaBinaryAt(
|
||||
});
|
||||
console.log(chalk.dim(`Verified ${asset.digest}`));
|
||||
|
||||
console.log(chalk.dim("Installing update..."));
|
||||
await replaceBinaryForUpdate({
|
||||
targetPath,
|
||||
tempPath,
|
||||
backupPath,
|
||||
expectedVersion,
|
||||
verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
|
||||
// Serialize the target swap and stale-artifact sweep per target so two
|
||||
// overlapping `omp update` runs never replace the same binary concurrently
|
||||
// or reclaim each other's live backup/temp files. The download above writes
|
||||
// to a unique temp path and is safe to overlap; only the swap is shared.
|
||||
await withFileLock(targetPath, async () => {
|
||||
console.log(chalk.dim("Installing update..."));
|
||||
await replaceBinaryForUpdate({
|
||||
targetPath,
|
||||
tempPath,
|
||||
backupPath,
|
||||
expectedVersion,
|
||||
verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
|
||||
});
|
||||
// Reclaim backups from earlier updates whose owning process has since exited.
|
||||
await sweepStaleUpdateArtifacts(targetPath);
|
||||
});
|
||||
// Reclaim backups from earlier updates whose owning process has since exited.
|
||||
await sweepStaleBackups(targetPath);
|
||||
printVerifiedVersion(expectedVersion);
|
||||
console.log(chalk.dim(`Restart ${APP_NAME} to use the new version`));
|
||||
}
|
||||
@@ -1421,7 +1555,8 @@ export async function updateViaShimTakeover(
|
||||
const binaryName = options.binaryName ?? getBinaryName();
|
||||
const launcherDir = path.dirname(shimPath);
|
||||
const exePath = path.join(launcherDir, `${APP_NAME}.exe`);
|
||||
const tempPath = `${exePath}.new`;
|
||||
const attempt = `${Date.now()}.${process.pid}.${updateAttemptSeq++}`;
|
||||
const tempPath = `${exePath}.${attempt}.new`;
|
||||
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
|
||||
console.log(chalk.dim(`Downloading ${binaryName}…`));
|
||||
await downloadVerifiedBinary({
|
||||
@@ -1432,65 +1567,69 @@ export async function updateViaShimTakeover(
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
console.log(chalk.dim(`Verified ${asset.digest}`));
|
||||
|
||||
console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
|
||||
await fs.promises.rename(tempPath, exePath);
|
||||
// Retire the shims so PATH resolution lands on the new exe. Renamed, not
|
||||
// deleted: restorable on verification failure, and Windows permits
|
||||
// renaming a batch file that is still executing. A shim that cannot be
|
||||
// renamed (held open without delete sharing) is rewritten in place as a
|
||||
// forwarder to the exe — write and rename take different Windows locks,
|
||||
// so one can succeed where the other fails.
|
||||
const backupSuffix = `${Date.now()}.${process.pid}.bak`;
|
||||
const retired: Array<{ launcher: string; backup: string }> = [];
|
||||
const forwarded: Array<{ launcher: string; original: string }> = [];
|
||||
const stuck: string[] = [];
|
||||
for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
|
||||
const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
|
||||
const backup = `${launcher}.${backupSuffix}`;
|
||||
try {
|
||||
await fs.promises.rename(launcher, backup);
|
||||
retired.push({ launcher, backup });
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) continue;
|
||||
// Serialize the launcher swap and artifact sweep so two overlapping updates
|
||||
// never retire the same shims or reclaim a live run's backup before its
|
||||
// verification can roll it back.
|
||||
await withFileLock(exePath, async () => {
|
||||
console.log(chalk.dim(`Installing ${APP_NAME}.exe beside the script launcher...`));
|
||||
await fs.promises.rename(tempPath, exePath);
|
||||
// Retire the shims so PATH resolution lands on the new exe. Renamed, not
|
||||
// deleted: restorable on verification failure, and Windows permits
|
||||
// renaming a batch file that is still executing. A shim that cannot be
|
||||
// renamed (held open without delete sharing) is rewritten in place as a
|
||||
// forwarder to the exe — write and rename take different Windows locks,
|
||||
// so one can succeed where the other fails.
|
||||
const backupSuffix = `${attempt}.bak`;
|
||||
const retired: Array<{ launcher: string; backup: string }> = [];
|
||||
for (const ext of ["", ".cmd", ".ps1", ".bat"]) {
|
||||
const launcher = path.join(launcherDir, `${APP_NAME}${ext}`);
|
||||
const backup = `${launcher}.${backupSuffix}`;
|
||||
try {
|
||||
const original = await Bun.file(launcher).text();
|
||||
await Bun.write(launcher, SHIM_FORWARDERS[ext]);
|
||||
forwarded.push({ launcher, original });
|
||||
} catch {
|
||||
stuck.push(launcher);
|
||||
await fs.promises.rename(launcher, backup);
|
||||
retired.push({ launcher, backup });
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) continue;
|
||||
try {
|
||||
const original = await Bun.file(launcher).text();
|
||||
await Bun.write(launcher, SHIM_FORWARDERS[ext]);
|
||||
forwarded.push({ launcher, original });
|
||||
} catch {
|
||||
stuck.push(launcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the exe by its explicit path: $which cached the shim path when
|
||||
// the update target was resolved, and the shim was just renamed away, so
|
||||
// a PATH re-resolution here would test a file that no longer exists.
|
||||
const verify = options.verifyBinary ?? verifyBinaryAtPath;
|
||||
const verification = await verify(exePath, expectedVersion);
|
||||
if (!verification.ok) {
|
||||
for (const { launcher, backup } of retired) {
|
||||
try {
|
||||
await fs.promises.rename(backup, launcher);
|
||||
} catch {}
|
||||
// Verify the exe by its explicit path: $which cached the shim path when
|
||||
// the update target was resolved, and the shim was just renamed away, so
|
||||
// a PATH re-resolution here would test a file that no longer exists.
|
||||
const verify = options.verifyBinary ?? verifyBinaryAtPath;
|
||||
const verification = await verify(exePath, expectedVersion);
|
||||
if (!verification.ok) {
|
||||
for (const { launcher, backup } of retired) {
|
||||
try {
|
||||
await fs.promises.rename(backup, launcher);
|
||||
} catch {}
|
||||
}
|
||||
for (const { launcher, original } of forwarded) {
|
||||
try {
|
||||
await Bun.write(launcher, original);
|
||||
} catch {}
|
||||
}
|
||||
await unlinkIfExists(exePath);
|
||||
throw new Error(
|
||||
`${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
|
||||
);
|
||||
}
|
||||
for (const { launcher, original } of forwarded) {
|
||||
try {
|
||||
await Bun.write(launcher, original);
|
||||
} catch {}
|
||||
for (const { backup } of retired) {
|
||||
await removeBackupBestEffort(backup);
|
||||
}
|
||||
await unlinkIfExists(exePath);
|
||||
throw new Error(
|
||||
`${formatVerificationFailure(verification, expectedVersion)}; restored previous ${APP_NAME} launcher`,
|
||||
);
|
||||
}
|
||||
for (const { backup } of retired) {
|
||||
await removeBackupBestEffort(backup);
|
||||
}
|
||||
// Reclaim exe backups and retired-shim leftovers from earlier attempts.
|
||||
for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
|
||||
await sweepStaleBackups(path.join(launcherDir, `${APP_NAME}${ext}`));
|
||||
}
|
||||
// Reclaim exe backups and retired-shim leftovers from earlier attempts.
|
||||
for (const ext of [".exe", "", ".cmd", ".ps1", ".bat"]) {
|
||||
await sweepStaleUpdateArtifacts(path.join(launcherDir, `${APP_NAME}${ext}`));
|
||||
}
|
||||
});
|
||||
for (const { launcher } of forwarded) {
|
||||
console.log(chalk.dim(`Converted ${launcher} to a forwarder (it could not be removed).`));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,21 @@ import { commands } from "../cli-commands";
|
||||
const ROOT_COMMAND = "launch";
|
||||
const SHELLS = ["bash", "zsh", "fish"] as const;
|
||||
|
||||
/** Generate a completion script from the live command registry. */
|
||||
export async function generateLiveCompletion(shell: Shell): Promise<string> {
|
||||
const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
|
||||
const map = new Map<string, CommandCtor>();
|
||||
const aliasMap = new Map<string, readonly string[]>();
|
||||
for (const { entry, Cmd } of loaded) {
|
||||
map.set(entry.name, Cmd);
|
||||
const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
|
||||
aliasMap.set(entry.name, [...merged]);
|
||||
}
|
||||
|
||||
const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
|
||||
return generateCompletion(shell, buildSpec(config, ROOT_COMMAND, aliasMap));
|
||||
}
|
||||
|
||||
export default class Completions extends Command {
|
||||
static description = commandHelp.description;
|
||||
static args = {
|
||||
@@ -39,20 +54,7 @@ export default class Completions extends Command {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load every command class so we can read its static flag/arg descriptors,
|
||||
// and collect aliases from both the registration table and the class.
|
||||
const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() })));
|
||||
const map = new Map<string, CommandCtor>();
|
||||
const aliasMap = new Map<string, readonly string[]>();
|
||||
for (const { entry, Cmd } of loaded) {
|
||||
map.set(entry.name, Cmd);
|
||||
const merged = new Set<string>([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]);
|
||||
aliasMap.set(entry.name, [...merged]);
|
||||
}
|
||||
|
||||
const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map };
|
||||
const spec = buildSpec(config, ROOT_COMMAND, aliasMap);
|
||||
await Bun.write(Bun.stdout, generateCompletion(shell, spec));
|
||||
await Bun.write(Bun.stdout, await generateLiveCompletion(shell));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { CONFIG_DIR_NAME, getConfigAgentDirName, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { resolveClaudePaths } from "./config/claude-paths";
|
||||
import { expandTilde } from "./tools/path-utils";
|
||||
|
||||
export * from "./config/config-file";
|
||||
@@ -76,12 +77,12 @@ export function getChangelogPath(): string | undefined {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Config directory bases in priority order (highest first).
|
||||
* User-level: ~/.omp/agent, ~/.claude, ~/.codex, ~/.gemini
|
||||
* User-level: ~/.omp/agent, Claude's active config directory, ~/.codex, ~/.gemini
|
||||
* Project-level: .omp, .claude, .codex, .gemini
|
||||
*/
|
||||
const USER_CONFIG_BASES = priorityList.map(({ dir, globalAgentDir }) => ({
|
||||
base: () => path.join(os.homedir(), globalAgentDir ? globalAgentDir() : dir),
|
||||
base: () =>
|
||||
dir === ".claude" ? resolveClaudePaths().configDir : path.join(os.homedir(), globalAgentDir?.() ?? dir),
|
||||
name: dir,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
/** Paths to Claude Code's user data and configuration file. */
|
||||
export interface ClaudePaths {
|
||||
configDir: string;
|
||||
configFile: string;
|
||||
}
|
||||
|
||||
/** Resolves Claude Code's user paths, honoring `CLAUDE_CONFIG_DIR`. */
|
||||
export function resolveClaudePaths(home: string = os.homedir()): ClaudePaths {
|
||||
const override = process.env.CLAUDE_CONFIG_DIR?.trim();
|
||||
if (override) {
|
||||
const configDir = path.resolve(override);
|
||||
return { configDir, configFile: path.join(configDir, ".claude.json") };
|
||||
}
|
||||
return { configDir: path.join(home, ".claude"), configFile: path.join(home, ".claude.json") };
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
resolveOllamaModelCacheProviderId,
|
||||
} from "@oh-my-pi/pi-catalog/provider-models";
|
||||
import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse";
|
||||
import { isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
|
||||
import { resolveProviderModelReference } from "../config/model-resolver";
|
||||
import { generateCodexAttestation } from "../live/attestation";
|
||||
import type { AuthStorage } from "../session/auth-storage";
|
||||
@@ -246,7 +246,7 @@ export class ModelRegistry {
|
||||
(isBunTestRuntime()
|
||||
? () => Promise.reject(new Error("network disabled in model-registry runtime test"))
|
||||
: wrapFetchForExtraCa(fetch));
|
||||
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
|
||||
this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath ?? path.join(getAgentDir(), "models.yml"));
|
||||
this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
|
||||
// Set up fallback resolver for custom provider API keys
|
||||
this.authStorage.setFallbackResolver(provider => {
|
||||
|
||||
@@ -67,18 +67,18 @@ const HOST_PROBE_TIMEOUT_MS = 500;
|
||||
|
||||
/**
|
||||
* Run a best-effort discovery probe and return its trimmed stdout, or
|
||||
* `undefined` when the command fails, produces no output, or exceeds
|
||||
* {@link HOST_PROBE_TIMEOUT_MS}. On timeout the child is killed with SIGKILL so
|
||||
* a wedged interop pipe cannot hang startup; the killed/non-zero exit is then
|
||||
* reported as "unavailable" and discovery falls back to the Linux
|
||||
* `$HOME`/`~/.omp` candidates.
|
||||
* `undefined` when the command fails, produces no output, or exceeds the
|
||||
* timeout. On timeout the child is killed with SIGKILL so a wedged interop pipe
|
||||
* cannot hang startup; the killed/non-zero exit is then reported as
|
||||
* "unavailable" and discovery falls back to the Linux `$HOME`/`~/.omp`
|
||||
* candidates.
|
||||
*/
|
||||
export function runHostProbe(cmd: string[]): string | undefined {
|
||||
export function runHostProbe(cmd: string[], timeoutMs = HOST_PROBE_TIMEOUT_MS): string | undefined {
|
||||
try {
|
||||
const result = Bun.spawnSync(cmd, {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
timeout: HOST_PROBE_TIMEOUT_MS,
|
||||
timeout: timeoutMs,
|
||||
killSignal: "SIGKILL",
|
||||
});
|
||||
if (result.exitCode !== 0) return undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { type SlashCommand, slashCommandCapability } from "../capability/slash-c
|
||||
import { type SystemPrompt, systemPromptCapability } from "../capability/system-prompt";
|
||||
import { type CustomTool, toolCapability } from "../capability/tool";
|
||||
import type { LoadContext, LoadResult } from "../capability/types";
|
||||
import { resolveClaudePaths } from "../config/claude-paths";
|
||||
import { settings } from "../config/settings";
|
||||
import {
|
||||
calculateDepth,
|
||||
@@ -34,11 +35,10 @@ const DISPLAY_NAME = "Claude Code";
|
||||
const PRIORITY = 80;
|
||||
const CONFIG_DIR = ".claude";
|
||||
|
||||
/**
|
||||
* Get user-level .claude path.
|
||||
*/
|
||||
/** Get the active user-level Claude Code directory. */
|
||||
function getUserClaude(ctx: LoadContext): string {
|
||||
return path.join(ctx.home, CONFIG_DIR);
|
||||
const { configDir } = resolveClaudePaths(ctx.home);
|
||||
return configDir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +60,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
||||
const items: MCPServer[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const userBase = getUserClaude(ctx);
|
||||
const userClaudeJson = path.join(ctx.home, ".claude.json");
|
||||
const { configDir: userBase, configFile: userClaudeJson } = resolveClaudePaths(ctx.home);
|
||||
const userMcpJson = path.join(userBase, "mcp.json");
|
||||
|
||||
const projectBase = path.join(ctx.cwd, CONFIG_DIR);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { invalidate as invalidateFsCache, readDirEntries, readFile } from "../ca
|
||||
import { parseRuleConditionAndScope, type Rule, type RuleFrontmatter } from "../capability/rule";
|
||||
import type { Skill, SkillFrontmatter } from "../capability/skill";
|
||||
import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
|
||||
import { resolveClaudePaths } from "../config/claude-paths";
|
||||
import type { MCPRequestIdFormat } from "../mcp/types";
|
||||
import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
|
||||
import { normalizeToolNames } from "../tools/builtin-names";
|
||||
@@ -90,10 +91,9 @@ export type SourceId = keyof typeof SOURCE_PATHS;
|
||||
*/
|
||||
export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
|
||||
// Native user config is profile-scoped via getAgentDir() (the active profile's
|
||||
// agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools
|
||||
// (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
|
||||
// resolving against ctx.home below.
|
||||
// agent dir), matching builtin.ts and getMCPConfigPath("user").
|
||||
if (source === "native") return path.join(getAgentDir(), subpath);
|
||||
if (source === "claude") return path.join(resolveClaudePaths(ctx.home).configDir, subpath);
|
||||
const paths = SOURCE_PATHS[source];
|
||||
if (!paths.userAgent) return null;
|
||||
return path.join(ctx.home, paths.userAgent, subpath);
|
||||
@@ -903,20 +903,21 @@ export function registerPluginCacheInvalidator(invalidator: () => void): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* List all installed Claude Code plugin roots from the plugin cache.
|
||||
* Reads ~/.claude/plugins/installed_plugins.json and ~/.omp/plugins/installed_plugins.json,
|
||||
* and optionally the nearest project-scoped registry resolved from `cwd`.
|
||||
* List all installed Claude Code plugin roots from its active plugin cache and
|
||||
* ~/.omp/plugins/installed_plugins.json, plus the nearest project registry when present.
|
||||
*
|
||||
* Results are cached per home, project registry, and canonical active project.
|
||||
* Results are cached per Claude and OMP config directories, project registry, and canonical active project.
|
||||
*/
|
||||
export async function listClaudePluginRoots(
|
||||
home: string,
|
||||
cwd?: string,
|
||||
): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
|
||||
const claudeConfigDir = resolveClaudePaths(home).configDir;
|
||||
const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
|
||||
const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
|
||||
const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd;
|
||||
const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null;
|
||||
const cacheKey = `${home}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
|
||||
const cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
|
||||
const cached = pluginRootsCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -926,7 +927,7 @@ export async function listClaudePluginRoots(
|
||||
const canonicalClaudeProjectPaths = new Map<string, string | null>();
|
||||
|
||||
// ── Claude Code registry ──────────────────────────────────────────────────
|
||||
const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json");
|
||||
const registryPath = path.join(claudeConfigDir, "plugins", "installed_plugins.json");
|
||||
const content = await readFile(registryPath);
|
||||
|
||||
if (content) {
|
||||
@@ -983,7 +984,7 @@ export async function listClaudePluginRoots(
|
||||
// In production `home` is `os.homedir()`, so `getPluginsDir(home)` resolves to the
|
||||
// same XDG-aware path the marketplace writer uses (reads and writes always agree).
|
||||
// Tests pass a temp dir, which short-circuits the resolver for deterministic isolation.
|
||||
const ompRegistryPath = path.join(getPluginsDir(home), "installed_plugins.json");
|
||||
// Computed before the cache lookup because isolated SDK homes select distinct OMP registries.
|
||||
const ompContent = await readFile(ompRegistryPath);
|
||||
if (ompContent) {
|
||||
const ompRegistry = parseClaudePluginsRegistry(ompContent);
|
||||
@@ -1107,7 +1108,7 @@ export function clearClaudePluginRootsCache(): void {
|
||||
* installing/uninstalling/enabling/disabling plugins.
|
||||
*/
|
||||
export function clearPluginRootsAndCaches(extraPaths?: readonly string[]): void {
|
||||
invalidateFsCache(path.join(os.homedir(), ".claude", "plugins", "installed_plugins.json"));
|
||||
invalidateFsCache(path.join(resolveClaudePaths().configDir, "plugins", "installed_plugins.json"));
|
||||
invalidateFsCache(path.join(getPluginsDir(), "installed_plugins.json"));
|
||||
for (const p of extraPaths ?? []) invalidateFsCache(p);
|
||||
clearClaudePluginRootsCache();
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
ExtensionError,
|
||||
ExtensionEvent,
|
||||
ExtensionFlag,
|
||||
ExtensionMode,
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
@@ -342,6 +343,7 @@ interface ToolRegistrationScope {
|
||||
|
||||
export class ExtensionRunner {
|
||||
#uiContext: ExtensionUIContext;
|
||||
#mode: ExtensionMode = "print";
|
||||
#toolApprovalPreviewWaiter?: (toolCallId: string) => Promise<void>;
|
||||
#errorListeners: Set<ExtensionErrorListener> = new Set();
|
||||
#getModel: () => Model | undefined = () => undefined;
|
||||
@@ -525,6 +527,7 @@ export class ExtensionRunner {
|
||||
contextActions: ExtensionContextActions,
|
||||
commandContextActions?: ExtensionCommandContextActions,
|
||||
uiContext?: ExtensionUIContext,
|
||||
mode: ExtensionMode = "print",
|
||||
): void {
|
||||
// Copy actions into the shared runtime (all extension APIs reference this)
|
||||
this.runtime.sendMessage = actions.sendMessage;
|
||||
@@ -573,6 +576,7 @@ export class ExtensionRunner {
|
||||
}
|
||||
|
||||
this.#uiContext = uiContext ?? noOpUIContext;
|
||||
this.#mode = mode;
|
||||
this.#initialized = true;
|
||||
|
||||
// Drain events buffered by emitCredentialDisabled() before initialize ran. The
|
||||
@@ -953,6 +957,7 @@ export class ExtensionRunner {
|
||||
const getModel = model ? () => model : this.#getModel;
|
||||
return {
|
||||
ui: this.#uiContext,
|
||||
mode: this.#mode,
|
||||
getContextUsage: () => this.#getContextUsageFn(),
|
||||
compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions),
|
||||
getAsyncJobSnapshot: () => this.#getAsyncJobSnapshotFn(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user