Routing Claude Code through the proxy disabled its on-demand tool loading:
with a custom ANTHROPIC_BASE_URL and ENABLE_TOOL_SEARCH unset, Claude Code
stops deferring MCP/system tool schemas behind the server-side Tool Search
Tool and materializes them all into local context (~25K tokens) — the
opposite of what a context-optimization proxy should do.
Root cause is a client-side gate in Claude Code (isToolSearchEnabledOptimistic):
deferral is disabled when ENABLE_TOOL_SEARCH is unset AND provider is
first-party AND the base-URL host is not api.anthropic.com. It is a one-way
URL check, not a capability handshake, so no proxy/response header can flip
it. The only lever is the ENABLE_TOOL_SEARCH env var Claude Code reads at
startup.
Changes:
- wrap claude: inject ENABLE_TOOL_SEARCH into the launched Claude Code env
(default "true"; --tool-search true|auto|auto:N|false; a pre-set env value
is respected; blank is treated as unset). Keeps deferral on through the proxy.
- proxy: emit a one-time, actionable hint when a Claude Code request is
detected loading tools eagerly (for users who run `claude` manually). Gated
on a cheap one-shot flag and wrapped so it can never fail a request.
- docs: troubleshooting section with before/after verification.
- tests: 30 unit tests (value validation, injection precedence, detection,
hint content, one-shot guard).
Display the resolved upstream API targets (Anthropic, OpenAI, Gemini,
Cloud Code) in the proxy startup banner so users can verify their
custom endpoint configuration at a glance.
The new UPSTREAM TARGETS section appears between the Backend line and
the FEATURES section. URLs are resolved through the existing
resolve_api_targets pipeline, which normalizes trailing /v1 suffixes
and applies defaults for unconfigured providers.
Closes#583
Co-authored-by: vipin-si <vipin-si@users.noreply.github.com>
The existing suppression in headroom/memory/adapters/embedders.py fires at
module-import time for that file, but sentence_transformers is imported lazily
(only when SentenceTransformer() is instantiated in a worker process). This
means the log levels are set too late: httpx and huggingface_hub have already
emitted their INFO/WARNING records by the time embedders.py is first imported.
Fix: move the suppression to headroom/cli/proxy.py module level. This file is
imported during CLI registration (_register_commands in main.py), well before
any worker forks or ML initialisation. Setting log levels here guarantees they
are in place for every code path that eventually loads sentence_transformers.
Changes:
- logging.getLogger("httpx").setLevel(WARNING) -- suppress manifest HEAD/GET INFO
- logging.getLogger("huggingface_hub").setLevel(ERROR)
- logging.getLogger("huggingface_hub.utils._http").setLevel(ERROR)
- logging.getLogger("sentence_transformers").setLevel(WARNING)
- os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1") -- pre-empt env check
- os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
- warnings.filterwarnings to suppress unauthenticated/HF_TOKEN UserWarnings
Eliminates ~50 noisy startup log lines with 8 workers (6 HEAD requests each).
Complementary to PR #619 which adds the same suppression in embedders.py;
this PR adds the MISSING earlier suppression point.
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
Co-authored-by: Devanshi Vyas <dnv2103@columbia.edu>
* fix(deps): add missing runtime deps to [code] and [proxy] extras
- Add gunicorn>=21.0.0 to the [proxy] extra
The proxy docs (docs/content/docs/proxy.mdx and wiki/proxy.md) show
gunicorn as the recommended production deployment server:
pip install gunicorn
gunicorn headroom.proxy.server:app --worker-class uvicorn.workers.UvicornWorker
Users installing headroom-ai[proxy] for production get uvicorn (already
declared) but had to discover and install gunicorn manually. Adding it
to [proxy] removes that friction.
Investigation notes:
- [code] only needs tree-sitter-language-pack (already declared).
code_compressor.py has zero numpy imports. The kompress fallback
inside code_compressor.py is guarded by ImportError and requires [ml].
- numpy is correctly declared in [relevance] (numpy>=1.24.0) and pulled
transitively by sentence-transformers in [memory]. It is NOT needed
under [code].
- tree-sitter is a transitive dep of tree-sitter-language-pack (requires
tree-sitter>=0.25.2) so it does not need an explicit entry.
* docs(changelog): add entry for gunicorn proxy dep fix
style(tests): ruff format test_provider_proxy_routes.py (blank lines after docstrings)
* fix(deps): move gunicorn to [proxy-prod] extra, add Windows guard
- Remove gunicorn from [proxy] so dev, CI, and Windows users are not
forced to install a Unix-only package that does nothing on Windows
- Add new [proxy-prod] extra that includes [proxy] + gunicorn with a
sys_platform != 'win32' environment marker
- Production users: pip install 'headroom-ai[proxy,proxy-prod]'
- Update CHANGELOG to reflect the new extra name
* fix(devcontainer): bump uv floor to >=0.11.0 for lockfile compatibility
uv 0.6.17 (previously pinned) cannot parse lockfiles generated by
uv >= 0.11.x. The validate CI job (triggered by pyproject.toml
changes) was failing with 'Failed to parse uv.lock'. Loosening the
pin to >=0.11.0 picks up the matching format parser while keeping the
Docker layer cacheable with a range rather than an exact pin.
* fix(devcontainer): skip gitpython wheel filename check in uv sync
gitpython 3.1.47 on PyPI has wheel gitpython-3.1.46-py3-none-any.whl
(wrong filename). uv >=0.11.19 strict filename validation rejects this
lockfile entry. UV_SKIP_WHEEL_FILENAME_CHECK=1 bypasses the check until
the upstream lockfile is regenerated with a corrected entry.
* fix(deps): correct gitpython version in uv.lock to match actual wheel
gitpython 3.1.47 on PyPI was uploaded with sdist/wheel files named
gitpython-3.1.46.*. The version field in uv.lock said 3.1.47 but all
download URLs reference 3.1.46 files, causing uv >=0.11.19 to refuse
to parse the lockfile with a version-mismatch error.
Change the version field to 3.1.46 so the entry is internally
consistent. Also revert the now-unnecessary UV_SKIP_WHEEL_FILENAME_CHECK
workaround from post-create.sh.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes#595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs: add enterprise.md
* docs: add link to enterprisemd in README
* fix(copilot): restore generic endpoint for non-subscription OAuth (#610) (#612)
* fix(copilot): restore generic endpoint for non-subscription OAuth (#610)
0.23.0 re-pointed the shared Copilot OAuth branch from the generic
api.githubcopilot.com host to the account-specific endpoints.api host
returned by /copilot_internal/user, and made resolve_copilot_api_url
ignore the GITHUB_COPILOT_API_URL override whenever a token resolved.
That change was meant to add --subscription, but it also altered the
pre-existing non-subscription OAuth flow that worked on 0.22.4. The
account host does not serve newer models (e.g. gpt-5.4) on the responses
API, so wrapped requests began failing with unsupported-model errors
while plain Copilot and 0.22.4 kept working.
Restore 0.22.4 routing for non-subscription OAuth (generic host, still
overridable) and keep account resolution only for --subscription.
resolve_copilot_api_url now honors GITHUB_COPILOT_API_URL first, so the
override escape hatch works for every path. BYOK is unaffected.
Add a regression suite that mocks a successful user-info response, the
real-world path the prior test never exercised (it relied on the network
call failing in CI and falling back to the generic host).
* fix(copilot): route subscription + OAuth through the generic host (#610)
The 0.23.0 endpoint resolution derived the Copilot API host from
/copilot_internal/user (endpoints.api), which returns a segmented host
(e.g. api.individual.githubcopilot.com) that does not serve newer models
on the responses API and is not the host the official Copilot client
routes with (that comes from the token-exchange endpoint). --subscription
used the identical resolution, so it carried the same latent regression
as the non-subscription OAuth path.
Make Copilot host resolution override -> generic for BOTH --subscription
and the implicit OAuth path, and stop using user-info to route. Accounts
that require a dedicated host (enterprise / data residency) pin it via
GITHUB_COPILOT_API_URL. resolve_copilot_api_url no longer makes a network
call; _fetch_copilot_user_info is retained for token validation.
Update the subscription smoke tests that encoded the old account-host
assumption, and add wrap-level + unit coverage that --subscription routes
to the generic host even when user-info advertises an account host, and
that the GITHUB_COPILOT_API_URL override flows through both paths.
* docs(copilot): document generic-host routing + enterprise override (#610)
Spell out the routing contract introduced by the #610 fix so enterprise
users have a supported path. Headroom routes wrapped Copilot hosted
traffic (--subscription and OAuth) to the generic api.githubcopilot.com,
and accounts on a dedicated host (Enterprise Cloud data residency, egress
proxy) pin it via GITHUB_COPILOT_API_URL.
- copilot --help: note the generic host + GITHUB_COPILOT_API_URL override.
- TESTING-copilot-subscription.md: add "API host & Enterprise / data
residency" section; correct the stale api.*.githubcopilot.com claim; and
invite enterprise tenants who want token-exchange-based auto-detection to
open an issue.
- integration-guide.md: short hosted-host + override note in the Copilot
section.
* fix(wrap): report unbindable proxy ports (#602)
* fix(proxy): fail-open on corrupt golden bytes instead of RuntimeError (#603)
* fix(proxy): fail-open on corrupt golden bytes instead of RuntimeError
Permanent session corruption: once golden bytes become unreadable
(UnicodeDecodeError / JSONDecodeError), every subsequent request for
the session raised RuntimeError, returning 500 until proxy restart.
Fix: log at ERROR level and recover — skip the corrupt memory tool,
or regenerate a fresh CCR definition — rather than propagating
RuntimeError and permanently breaking the session.
Also change proxy_inbound_request_aborted from logger.info to
logger.error with exc_info=True so tracebacks appear in logs.
Closes: proxy silent-500 sessions in the wild (observed 2026-06-04)
* fix(tests): re-enable headroom log propagation in corrupt-bytes tests
configure_proxy_logging() sets headroom_logger.propagate = False to prevent
duplicate writes when the proxy redirects stderr to a log file. In CI the
proxy initialises its logging stack before the test suite, leaving propagation
disabled. pytest's caplog handler attaches to the root logger, so records that
stop at the headroom logger are never captured.
Added _enable_headroom_log_propagation autouse fixture that temporarily
re-enables propagation for the duration of each test, making caplog capture
work regardless of the surrounding logging configuration.
* fix(tests): remove unused imports from corrupt-bytes regression tests
Remove json, SessionCcrTracker, and SessionToolTracker imports that were
imported but never referenced in the test body. Fixes ruff F401 and I001
lint errors reported by CI.
---------
Co-authored-by: Patrick Ancillotti <patrick.ancillotti@people.inc>
* fix(startup): suppress log noise from litellm, trafilatura, HF hub, and tiktoken warning
* fix(startup): suppress httpx INFO logs from sentence_transformers HEAD checks
* docs(changelog): add entry for startup log noise suppression fixes
* refactor(startup): extract hf_hub_download_local_first into onnx_runtime
The three _hub_download/_hub_dl helpers in embedders.py, onnx_router.py, and
kompress_compressor.py are identical -- try local cache first, fall back to
network download. Extract into a single hf_hub_download_local_first() function
in onnx_runtime.py (the natural home for shared ORT/HF utilities) and update
all three callers to use it.
* fix(lint): sort imports and remove unused _FALLBACK_WARNING_SHOWN import
* fix(lint): cast hf_hub_download return to str for mypy no-any-return
---------
Co-authored-by: Patrick Ancillotti <patrick.ancillotti@people.inc>
* ci: speed up GitHub Actions - path filters, caching, timeouts, version upgrades
Performance improvements:
- init-e2e.yml, wrap-e2e.yml: add path filters so e2e Docker builds only run when
e2e-related files change (saves ~10 min per irrelevant PR push)
- init-e2e.yml, wrap-e2e.yml: add concurrency groups to cancel superseded PR runs
- ci.yml: add pip caching to lint and build jobs
- ci.yml: cache actionlint + act binaries in workflow-validation (skip curl on hits)
- eval.yml: add pip caching to smoke-test and weekly-suite jobs
- docs.yml: add pip caching for mkdocs-material install
- rust.yml: replace cargo install --locked cargo-audit/deny with taiki-e/install-action
(prebuilt binaries; saves 2-5 min per audit run)
Bug fixes:
- docker.yml: fix actions/checkout@v6 -> @v4 (v6 does not exist; would break all
Docker builds on every release/PR touching docker paths)
Version upgrades:
- wagoid/commitlint-github-action: @v5 -> @v6
- devcontainers.yml: docker/setup-buildx-action@v3 -> @v4 (align with docker.yml)
Safety improvements:
- ci.yml: add timeout-minutes to all 13 jobs (changes, lint, build-wheel,
prefetch-model, test x4, test-extras, test-agno, commitlint, build,
workflow-validation, docker-native-e2e, windows-native-wrapper, macos-native-wrapper)
- docker.yml: add timeout-minutes to docker-build (75m), docker-manifest (20m),
promote-latest (10m)
- eval.yml: add timeout-minutes to smoke-test (30m); bump weekly-suite 60->90m
- rust.yml: add timeout-minutes to test (30m), wheels (45m), audit (20m)
Observed wall-clock impact on recent PRs:
- Init E2E and Wrap E2E were running on every single PR push regardless of content
- CI workflow was taking 12-17 min; path filters reduce unnecessary e2e runs to 0
* fix(ci): bust actionlint+act cache when workflow file changes
Static cache key 'ci-tools-actionlint-act-v1' never invalidated on
tool version updates. Switched to hashFiles('.github/workflows/ci.yml')
so the cache busts automatically whenever the download scripts are
updated to point at a newer release.
Flagged by adversarial review (Architecture + Testing/Reliability personas).
* fix(ci): add missing Dockerfile COPY paths to e2e path filters
e2e/init/Dockerfile and e2e/wrap/Dockerfile COPY files not covered
by the initial path filter set:
init-e2e: Cargo.toml, Cargo.lock, rust-toolchain.toml, uv.lock,
.claude-plugin, .github/plugin/**, plugins/headroom-agent-hooks/**
wrap-e2e: Cargo.toml, Cargo.lock, rust-toolchain.toml, uv.lock,
sdk/typescript/**, plugins/openclaw/**
Without these, a Rust toolchain bump or SDK change on a PR would
skip the e2e gate entirely, only catching it on the merge to main.
Flagged by adversarial review (Domain/Correctness persona).
* fix(devcontainer): upgrade uv to >=0.7.0 to parse uv.lock revision=3
* fix(devcontainer): set UV_SKIP_WHEEL_FILENAME_CHECK=1 in post-create.sh for gitpython wheel
* ci: bump actions/checkout and actions/setup-node to v5 (Node.js 20 EOL Jun 16)
* fix(devcontainer): export UV_SKIP_WHEEL_FILENAME_CHECK so uv run also skips wheel check
* ci: bump all GitHub Actions to latest versions (Node.js 24)
* fix(test): accept release-please-action v4 or v5 in workflow assertion
* fix(format): ruff format test_release_workflows.py
BM25Scorer.score_batch() ranks a real corpus of documents but weighted
every matched term with a constant idf=log(2.0), so a ubiquitous noise
word counted the same as a discriminative UUID. The _compute_idf() helper
needed to do this properly already existed (and was unit-tested) but was
never wired into scoring.
- Implement _compute_idf() with the standard floored BM25 IDF its docstring
documents: log((N - n + 0.5) / (n + 0.5) + 1).
- Thread an optional per-term idf_map through _bm25_score(); single-document
score() keeps the neutral log(2.0) weight (no corpus to estimate from).
- score_batch() now computes document frequency across the batch and builds
the IDF map, so rare/discriminative terms outrank corpus-wide terms in the
ranking that CompressionStore.search() and HybridScorer consume.
Adds tests covering the IDF formula and the batch ranking behaviour.
* fix(proxy): fail-open on corrupt golden bytes instead of RuntimeError
Permanent session corruption: once golden bytes become unreadable
(UnicodeDecodeError / JSONDecodeError), every subsequent request for
the session raised RuntimeError, returning 500 until proxy restart.
Fix: log at ERROR level and recover — skip the corrupt memory tool,
or regenerate a fresh CCR definition — rather than propagating
RuntimeError and permanently breaking the session.
Also change proxy_inbound_request_aborted from logger.info to
logger.error with exc_info=True so tracebacks appear in logs.
Closes: proxy silent-500 sessions in the wild (observed 2026-06-04)
* fix(tests): re-enable headroom log propagation in corrupt-bytes tests
configure_proxy_logging() sets headroom_logger.propagate = False to prevent
duplicate writes when the proxy redirects stderr to a log file. In CI the
proxy initialises its logging stack before the test suite, leaving propagation
disabled. pytest's caplog handler attaches to the root logger, so records that
stop at the headroom logger are never captured.
Added _enable_headroom_log_propagation autouse fixture that temporarily
re-enables propagation for the duration of each test, making caplog capture
work regardless of the surrounding logging configuration.
* fix(tests): remove unused imports from corrupt-bytes regression tests
Remove json, SessionCcrTracker, and SessionToolTracker imports that were
imported but never referenced in the test body. Fixes ruff F401 and I001
lint errors reported by CI.
---------
Co-authored-by: Patrick Ancillotti <patrick.ancillotti@people.inc>
* fix(copilot): restore generic endpoint for non-subscription OAuth (#610)
0.23.0 re-pointed the shared Copilot OAuth branch from the generic
api.githubcopilot.com host to the account-specific endpoints.api host
returned by /copilot_internal/user, and made resolve_copilot_api_url
ignore the GITHUB_COPILOT_API_URL override whenever a token resolved.
That change was meant to add --subscription, but it also altered the
pre-existing non-subscription OAuth flow that worked on 0.22.4. The
account host does not serve newer models (e.g. gpt-5.4) on the responses
API, so wrapped requests began failing with unsupported-model errors
while plain Copilot and 0.22.4 kept working.
Restore 0.22.4 routing for non-subscription OAuth (generic host, still
overridable) and keep account resolution only for --subscription.
resolve_copilot_api_url now honors GITHUB_COPILOT_API_URL first, so the
override escape hatch works for every path. BYOK is unaffected.
Add a regression suite that mocks a successful user-info response, the
real-world path the prior test never exercised (it relied on the network
call failing in CI and falling back to the generic host).
* fix(copilot): route subscription + OAuth through the generic host (#610)
The 0.23.0 endpoint resolution derived the Copilot API host from
/copilot_internal/user (endpoints.api), which returns a segmented host
(e.g. api.individual.githubcopilot.com) that does not serve newer models
on the responses API and is not the host the official Copilot client
routes with (that comes from the token-exchange endpoint). --subscription
used the identical resolution, so it carried the same latent regression
as the non-subscription OAuth path.
Make Copilot host resolution override -> generic for BOTH --subscription
and the implicit OAuth path, and stop using user-info to route. Accounts
that require a dedicated host (enterprise / data residency) pin it via
GITHUB_COPILOT_API_URL. resolve_copilot_api_url no longer makes a network
call; _fetch_copilot_user_info is retained for token validation.
Update the subscription smoke tests that encoded the old account-host
assumption, and add wrap-level + unit coverage that --subscription routes
to the generic host even when user-info advertises an account host, and
that the GITHUB_COPILOT_API_URL override flows through both paths.
* docs(copilot): document generic-host routing + enterprise override (#610)
Spell out the routing contract introduced by the #610 fix so enterprise
users have a supported path. Headroom routes wrapped Copilot hosted
traffic (--subscription and OAuth) to the generic api.githubcopilot.com,
and accounts on a dedicated host (Enterprise Cloud data residency, egress
proxy) pin it via GITHUB_COPILOT_API_URL.
- copilot --help: note the generic host + GITHUB_COPILOT_API_URL override.
- TESTING-copilot-subscription.md: add "API host & Enterprise / data
residency" section; correct the stale api.*.githubcopilot.com claim; and
invite enterprise tenants who want token-exchange-based auto-detection to
open an issue.
- integration-guide.md: short hosted-host + override note in the Copilot
section.
Merges of #564/#555 landed lint regressions that the old HF-429-masked CI hid:
- I001 unsorted imports in tests/repro_unsendable_panic.py (#564)
- ruff-format drift in helpers.py and code_compressor.py
- mypy arg-type on get_language() in code_compressor.py (#564 dropped the
type: ignore the old get_parser() call had)
ruff check + ruff format --check + mypy headroom all green.
- Add `Depends(_require_loopback)` to `/debug/memory` endpoint (was missing
while /debug/tasks, /debug/ws-sessions, /debug/warmup all had it)
- Guard `raise last_error` when last_error is None (retry_max_attempts=0 path
raised TypeError); add ProxyConfig.__post_init__ validation rejecting
retry_max_attempts < 1 when retry_enabled=True
- Make initialize_context_tool_session_baseline async; offload subprocess via
asyncio.to_thread so the blocking rtk/lean-ctx subprocess does not stall the
event loop; update call sites in server.py
- Take snapshot list() of SemanticCache._cache.values() before iterating in
get_memory_stats() to avoid dict-size-changed RuntimeError under async load
- Change memory_neo4j_password default from 'password' to '' and emit a
logger.warning at startup when backend=qdrant-neo4j and password is empty
- Replace hardcoded NEO4J_AUTH=neo4j/password in docker-compose.yml with
${NEO4J_AUTH:-neo4j/devpassword}; add .env.example with CHANGEME placeholder
- Format tests/test_provider_proxy_routes.py (pre-existing ruff format drift)
Fold ci-fast.yml into ci.yml: change-detection (paths-filter), build the Rust
ext once (fast cargo profile) shared via artifact, lint once, prefetch the
embedding model once (authenticated) into a shared cache, and run the suite as
4 offline shards. Preserve commitlint, build smoke, workflow-validation, and
the docker/windows/macos e2e jobs (heavy ones gated on paths-filter). CPU-only
torch throughout; least-privilege permissions. Removes ci-fast.yml.
PRs run one Python version x 4 shards; multi-version on main is a follow-up.
Resolves the CodeQL 'workflow does not contain permissions' advisory. No job in
this workflow writes contents/PRs/releases, so read-only is sufficient.
The release profile (lto=thin, codegen-units=1) is great for the shipped wheel
but slow to compile — it was the build-wheel long pole (~3m38s) gating the test
shards. Add [profile.ci] (no LTO, codegen-units=256, opt-level=1) and build the
CI wheel with --profile ci. Does not affect --release / shipped wheels.
Cold-cache run had all 4 shards download all-MiniLM in parallel -> HF 429'd a
shard. Add a prefetch-model job that fetches it once (huggingface_hub, no torch)
and warms the shared cache; shards then run with HF_HUB_OFFLINE=1 so they load
from cache with zero HF network calls (the 429 was on a cache-validation HEAD).
The prebuilt wheel installs headroom into site-packages, but tests run from the
repo root where ./headroom shadows it and has no compiled extension. Copy the
built _core.*.so into the source tree (no second cargo build) so
'import headroom._core' resolves.
Runs alongside ci.yml (does not touch required checks) so it can be validated
and timed on a real PR before cutover.
- changes: paths-filter skips everything for docs-only PRs
- build-wheel: compile the Rust ext ONCE via maturin, share via artifact
(today ci.yml rebuilds it ~7x across the matrix/extras/agno/build jobs)
- lint: ruff + mypy once
- test: 4 parallel shards via pytest-split, each a fresh runner VM so the
suite's shared-state tests (repo-root db, port 8787) can't collide
- CPU-only torch (drops the ~2.5GB CUDA stack) + cached HF model
Verified locally: YAML valid; pytest-split partitions the suite cleanly.
Release PRs were titled 'chore: release main' (no version), so release-please
couldn't extract the version to tag on merge — leaving each merged release PR
'autorelease: pending' and aborting all subsequent releases (jammed #498, #594;
both required manual tag+publish recovery). Pin the title to
'chore: release ${version}' so the bot tags automatically on merge.