Commit graph

2630 commits

Author SHA1 Message Date
chopratejas
8376630062 fix(core): introduce CompressionPolicy struct + auth_mode mapping (F2.1 c1/6)
Phase F2.1, commit 1 of 6. Pure additive change — no call site reads
this struct yet, no behavior change in main.

What lands:
- New `headroom_core::compression_policy::CompressionPolicy` struct
  with `live_zone_only: bool` and `cache_aligner_enabled: bool`.
- `CompressionPolicy::for_mode(AuthMode)` returns the F2.1 per-mode
  values: PAYG and OAuth aggressive (live-zone-not-only,
  cache-aligner on); Subscription live-zone-only with cache aligner
  disabled. The OAuth=PAYG match is intentional in F2.1 — F2.2 will
  diverge once telemetry collected during F2.1's main bake shows
  what OAuth users need.
- `From<crate::auth_mode::AuthMode> for crate::transforms::live_zone::AuthMode`
  to bridge the two `AuthMode` enums that exist in headroom-core
  (one in F1's classifier, one in the live-zone dispatcher; differ
  only by the dispatcher's `Unknown` sentinel for stored
  recommendation rows). Keeps the cross-module call sites clean —
  `mode.into()` instead of a hand-written match every time.

Per-mode values (F2.1 only — F2.2 will tune):
| Mode         | live_zone_only | cache_aligner_enabled |
|--------------|----------------|-----------------------|
| Payg         | false          | true                  |
| OAuth        | false          | true (= PAYG)         |
| Subscription | true           | false                 |

3 unit tests covering each variant. `oauth_matches_payg_today`
fails on purpose if F2.2 (or any well-meaning future change)
diverges OAuth from PAYG without updating the test — the divergence
must be deliberate.

Why two flags and not more in F2.1: closing #327/#388 cache-
instability complaints requires exactly these two gates. Anything
more is F2.2 tuning that benefits from real bake-time telemetry,
and a smaller F2.1 lands faster + fewer regression sites.

Why a struct instead of `match auth_mode { ... }` everywhere:
Phase E already has two PAYG-only gates (cache_control auto-
placement, prompt_cache_key injection). Adding two more without
centralisation means four duplicated match arms. The struct
collapses them and gives F2.2 one place to add fields.

Next commit (c2/6) plumbs the policy through the Rust proxy +
plugs the `auth_mode` parameter the OpenAI dispatchers are still
missing today.
2026-05-05 16:33:21 -07:00
Tejas Chopra
c8e0b36757
Merge pull request #383 from chopratejas/realign-E1-E2-tool-and-schema-sort
fix: PR-E1 + PR-E2 tool array sort + schema-key sort (Phase E)
2026-05-05 15:55:56 -07:00
chopratejas
9112fed937 fix: PR-E2 recursive JSON Schema key sort (Phase E)
Recursively sort JSON Schema object keys inside each tool's schema
so cache hits no longer depend on SDK-side serializer key-emission
order (some sort, some preserve insertion, some hash-randomize).

Wired into all three live-zone walkers, hooking the per-provider
schema location:

  - Anthropic: `tool["input_schema"]`
  - OpenAI Chat: `tool["function"]["parameters"]`
  - OpenAI Responses: `tool["function"]["parameters"]`

Same auth-mode gate as PR-E1 (PAYG only). NO marker check — the
`cache_control` marker lives on the tool object itself, not inside
the schema, so sorting schema keys never moves the marker. PR-E2
therefore runs even on tools that PR-E1 had to skip due to a
present marker; the integration test pins this behaviour.

Array semantics preserved: `oneOf`, `anyOf`, `allOf`, `prefixItems`
and any other ordered JSON Schema array keep customer order; only
object keys move. Idempotent — sorting an already-sorted schema
yields byte-identical bytes (workspace `preserve_order` feature
pins `serde_json::Map` emission to insertion order).

Tests: unit tests for nested keys, oneOf preservation, deep
nesting, and idempotency; integration tests boot the real proxy
and assert PAYG -> sorted at every level, OAuth -> SHA-256 byte-
equal, and PAYG-with-marker -> E1 skipped but E2 still runs.
2026-05-05 15:36:32 -07:00
chopratejas
4a3b76bcc8 fix: PR-E1 tool array deterministic sort (Phase E)
Sort `tools[]` alphabetically by name on the way out so cache hits no
longer depend on the customer-side iteration order (commonly hash-
randomized via `set()` / `dict`). Mutates request bytes only when:

  1. Auth mode is PAYG (`headroom_core::auth_mode::classify`).
  2. No tool already carries a `cache_control` marker (reordering
     would shift cache scope and silently void customer intent).

Every gate skip emits a structured `e1_skipped` event with `reason =
auth_mode | marker_present` so dashboards can see policy adoption.

Wired into all three live-zone walkers — Anthropic `/v1/messages`,
OpenAI `/v1/chat/completions`, OpenAI `/v1/responses` — plus the
Bedrock invoke + invoke-streaming entry points. Each passes
`auth_mode` (already pre-classified by Phase F PR-F1 middleware)
into the dispatcher so the gate evaluates without re-classifying.

Sort key uses `tool["name"]` (Anthropic) or `tool["function"]["name"]`
(OpenAI). Unnamed tools (rare; malformed inputs only) fall back to
MD5 of canonical-JSON serialization for a stable in-process key —
collision odds are astronomically small and `Vec::sort_by` is stable.

Tests: unit tests for sort + marker detection + idempotency + the
permutation property; integration tests boot the real proxy in front
of a wiremock upstream and assert PAYG -> sorted, OAuth/Subscription/
marker -> byte-equal passthrough (SHA-256).
2026-05-05 15:35:27 -07:00
Tejas Chopra
f0b40f11d5
Merge pull request #398 from chopratejas/fix-codex-subscription-openai-base-url
fix: inject openai_base_url so Codex subscription (ChatGPT plan) routes through proxy
2026-05-05 14:56:55 -07:00
chopratejas
7fb6d7fefd fix: remove env_key from injected Codex provider — fixes #393
Codex treats env_key as a hard requirement: if the named env var is
absent it throws "Missing environment variable" before the session
starts. Subscription (ChatGPT Plus) users don't have OPENAI_API_KEY
set, so injecting env_key = "OPENAI_API_KEY" blocks them at startup.

With env_key absent, api_key() returns Ok(None) and Codex falls through
to the existing CodexAuth (OAuth for subscription, ApiKey for PAYG) —
both modes authenticate correctly without startup errors.
2026-05-05 14:54:01 -07:00
Tejas Chopra
7a063554ce
Merge pull request #396 from chopratejas/x2-release-dry-run
feat(ci): X2 — PR-time release dry-run via path-filtered pull_request trigger
2026-05-05 14:45:19 -07:00
chopratejas
5c7a5b4857 fix: inject openai_base_url so Codex subscription (ChatGPT plan) routes through proxy
Codex subscription mode uses the built-in openai provider with
chatgpt.com/backend-api/codex as the default base URL, bypassing
OPENAI_BASE_URL and the custom model_provider setting entirely.

Setting openai_base_url in config.toml overrides that default for all
auth modes (API key and ChatGPT subscription), so both traffic paths
now flow through the Headroom proxy.

Also adds orphan-cleanup regex for openai_base_url in
_strip_codex_headroom_blocks and 5 new tests covering subscription
routing behaviour.
2026-05-05 14:33:41 -07:00
chopratejas
c090b617df chore(ci): drop ubuntu:20.04 + python 3.10 from smoke-import matrix
PR #396's first dry-run on its own changes failed three smoke matrix
entries — two fixed by PR #397's __libc_single_threaded shim (now on
main, this PR is rebased on top), one orthogonal: ubuntu:20.04 + 3.10.

Failure mode on ubuntu:20.04 + 3.10: deadsnakes PPA install path
stopped reliably provisioning python3.10-venv on focal once Ubuntu
20.04 hit End of Standard Support in May 2025. The error is from
apt-get, NOT from `import headroom._core` — the wheel never gets a
chance to load:

  E: Unable to locate package python3.10-venv

Continuing to promise wheel-runtime correctness on glibc 2.31 in CI
would require either pulling ESM-tier ubuntu:focal images (paid /
auth-gated) or pinning a specific deadsnakes snapshot URL — neither
of which we want owning long-term.

Floor coverage we keep:
- manylinux_2_28_x86_64  (glibc 2.28 — the floor we promise)
- manylinux_2_28_aarch64 (glibc 2.28 — same)
- ubuntu:22.04 + 3.12 x86_64 (glibc 2.35 — issue #355's reporter env)
- ubuntu:22.04 + 3.12 aarch64 (glibc 2.35 — arm equivalent)
- macos-14 + 3.13 (Apple Silicon native)

Test pin in tests/test_release_workflows.py
(test_release_workflow_has_smoke_import_wheel_gate) only requires the
manylinux floors, ubuntu:22.04, and macos-14, so this drop doesn't
break the structural invariant.
2026-05-05 14:23:46 -07:00
chopratejas
b9f84fa815 feat(ci): X2 — PR-time release dry-run via path-filtered pull_request trigger
The X1 smoke-import gate (PR #387) catches runtime symbol mismatches
on the wheel before publish, but only at release time. Recent break
patterns were upstream of that:

- #379 (docker bake `name=` regression in PR #376)
- #382 (sdist `os: ubuntu-latest` → `ubuntu-24.04` rename)
- #384 / #385 / #386 (glibc shim alias / link-order iterations)
- #387's own heredoc-indent regression that broke main on the FIRST
  release run after merge — the heredoc was inside `bash -ec '...'`,
  no PR-time check exercised it

X2 adds a `pull_request:` trigger to release.yml with a NARROW path
filter so the dry-run runs at PR time for changes that affect wheel
layout / release pipeline, but skips for source-only PRs to
`crates/headroom-core` / `crates/headroom-proxy`.

Path filter: release.yml, docker.yml, crates/headroom-py/**,
pyproject.toml, root Cargo.toml, Cargo.lock.

publish-pypi / publish-npm / publish-github-packages / publish-docker
/ create-release all gate on `github.event_name != 'pull_request'`,
so a PR run never publishes — the dry-run is build + collect-dist +
smoke-import only.

concurrency rules:
- PR runs: namespaced by PR number (`pr-N`), cancel-in-progress=true.
- main runs: namespaced by ref_name, cancel-in-progress=false (a
  tag-push release that's mid-flight must not be cancelled).

Test pin covers all four invariants (trigger, path filter, publish
gates, concurrency split). 21 tests in test_release_workflows.py,
all green locally.
2026-05-05 14:22:22 -07:00
Tejas Chopra
19a43f0d52
Merge pull request #397 from chopratejas/hotfix-libc-single-threaded
fix(crusher): shim __libc_single_threaded for glibc < 2.32 + extend audit
2026-05-05 14:20:52 -07:00
Tejas Chopra
e664f11260
Merge pull request #395 from chopratejas/fix-389-ccr-hash-bridge
fix(crusher): bridge SmartCrusher row-drop hash to Python compression_store (#389)
2026-05-05 14:20:08 -07:00
chopratejas
17d6207bf5 fix(crusher): shim __libc_single_threaded for glibc < 2.32 + extend audit
PR #396's X2 dry-run caught a wheel-import failure on the manylinux_2_28
floor matrix entry (both x86_64 and aarch64). Same class as #355:

  ImportError: ... undefined symbol: __libc_single_threaded

`__libc_single_threaded` is a single-byte char added in glibc 2.32.
Newer libstdc++ (gcc 11+) reads it inside `__cxa_thread_atexit_impl`
to elide locking on the single-threaded fast path. ORT prebuilt static
archives compiled with gcc-14.2.1 against glibc-2.38+ headers bake in
the reference. Users with glibc < 2.32 hit ImportError on
`import headroom._core`.

Latent since the ORT artifact bump that started using gcc 14. X1 is
the gate that catches it at release time; X2 caught it at PR time —
exactly as designed.

Fix:
1. glibc_compat.c adds Section B: `char __libc_single_threaded = 0;`
   Setting to 0 (multi-threaded) is safe; libstdc++ takes the locked
   slow path. Setting to 1 would race in any multithreaded Rust wheel.
2. build.rs adds `-Wl,-u,__libc_single_threaded` so the shim's archive
   members are pulled regardless of scan order.
3. audit_wheel_glibc_symbols.py POST_FLOOR_SYMBOLS adds the new
   symbol — verified locally: the audit now rejects the failing
   PR #396 wheel with the right message.
2026-05-05 13:59:21 -07:00
Tejas Chopra
2fc73d0f73
Merge pull request #380 from chopratejas/realign-E4-openai-prompt-cache-key
fix: PR-E4 OpenAI prompt_cache_key auto-injection (Phase E)
2026-05-05 13:00:21 -07:00
Tejas Chopra
e536d9b180
Merge pull request #392 from chopratejas/hotfix-smoke-heredoc-indent
fix(ci): stage smoke-import script as host file (broke main)
2026-05-05 12:59:38 -07:00
Tejas Chopra
218821e489
Merge pull request #394 from chopratejas/fix-390-telemetry-env
fix(telemetry): honour HEADROOM_TELEMETRY=off in /v1/telemetry collector
2026-05-05 12:59:24 -07:00
chopratejas
914a34fbd8 test(crusher): update compression_store hash test to expect SHA-256[:24]
Companion to the MD5→SHA-256 switch in 98d458f. The hash-pinning test
asserted `hashlib.md5(content.encode()).hexdigest()[:24]`; flip it to
the new function. Also expanded the failure message so the next
person debugging this knows why this gate exists and what they need
to verify if they're tempted to change the hash function again.

CI test (3.10/3.11/3.12/3.13) failed on this single assertion after
the SHA-256 switch; with the test updated, the rest of the
compression_store regression (76 tests) stays green.
2026-05-05 12:59:13 -07:00
chopratejas
af9e6282f8 fix(crusher): switch compression_store cache key from MD5 to SHA-256 (CodeQL #395)
CodeQL flagged `compression_store.store()`'s default MD5 cache key as
`py/weak-sensitive-data-hashing` after the explicit_hash refactor in
PR #395 brought the line into a new diff context.

First attempt: `usedforsecurity=False` + `# lgtm[...]` comment to
silence the alert without changing the hash. Both failed — CodeQL
ignores the hashlib parameter and our LGTM marker, the alert stayed
open on the PR.

Second attempt (this commit): drop MD5 entirely. The cache key is for
deduplication / lookup, not security or integrity, so any deterministic
function works. SHA-256[:24] gives the same 96-bit collision space as
MD5[:24] (~280 trillion entries for 50% collision under birthday
bound), is FIPS-clean, and CodeQL won't flag it.

Behaviour impact: zero. The cache is in-memory (no disk persistence),
so the same content always hashes deterministically under whichever
function is in use — there is no upgrade-time mismatch to manage.

Drive-by: pre-commit's sync-plugin-versions hook bumped marketplace +
plugin manifests to 0.20.28 since v0.20.27 was tagged on main.
2026-05-05 12:44:02 -07:00
chopratejas
a40d4a5f6d fix(crusher): bridge SmartCrusher row-drop hash to Python compression_store (#389)
SmartCrusher's row-drop and opaque-blob paths emitted `<<ccr:HASH ...>>`
markers and stashed the canonical payload only in the Rust process-local
CCR store. /v1/retrieve queries the Python compression_store, so every
retrieve call for a Rust-emitted marker returned 404 even though the
data was held in the Rust store.

Changes:

- Add `explicit_hash` parameter to `CompressionStore.store()`. Required
  to mirror entries keyed by hashes produced by another component
  (Rust SmartCrusher emits SHA-256[:12]; the Python store's default
  is MD5[:24]). Validates the hash is non-empty hex and raises
  `ValueError` otherwise — no silent fallback to MD5.
- Wire a Rust → Python store bridge in `headroom/transforms/
  smart_crusher.py`. After every `crush()`, `crush_array_json()`,
  `compact_document_json()`, and `_smart_crush_content()` call, walk
  the rendered output for `<<ccr:HASH>>` markers, fetch the canonical
  via `self._rust.ccr_get(hash)`, and mirror it into the Python
  compression_store under the same hash via `explicit_hash=`.
- Bridge is best-effort: failures log at debug; compression itself
  is never blocked. Marker parsing is structured (JSON walk +
  substring scan), no regex.

Tests:

- `tests/test_ccr_row_drop_store_bridge.py` — 9 cases covering:
  (1) row-drop populates Python store keyed by marker hash;
  (2) `_smart_crush_content` (the runtime path the proxy uses) also
  populates the store; (3) passthrough crushes do not write;
  (4) `ccr_config.enabled=False` skips both marker emission and store
  write; (5) distinct payloads → distinct hashes both retrievable;
  (6,7) `explicit_hash` round-trip + non-hex rejection; (8) full
  /v1/compress → /v1/retrieve integration matches the issue's
  reproducer; (9) unknown hashes still 404.

Addresses #389
2026-05-05 11:41:48 -07:00
chopratejas
9ddbdf2313 fix(telemetry): honour HEADROOM_TELEMETRY=off in /v1/telemetry collector (#390)
Two telemetry env vars existed in the codebase, only one was wired to
the /v1/telemetry endpoint:

- HEADROOM_TELEMETRY (documented opt-out, used by Supabase beacon and
  the Telemetry-Warning notice). Honoured: off / false / 0 / no /
  disable / disabled.
- HEADROOM_TELEMETRY_DISABLED (undocumented). Was the ONLY one the
  collector singleton consulted. Accepted only "1" or "true".

A user setting HEADROOM_TELEMETRY=off (the value from the docs) saw
/v1/telemetry continue to report enabled=true.

Fix: collector calls is_telemetry_enabled() (the same predicate the
beacon uses), so both env vars take effect. HEADROOM_TELEMETRY_DISABLED
remains accepted for back-compat.

Tests: parametrized regression covering all six documented OFF values
and a positive-path test for explicit ON / unset.

Drive-by: convert two pre-existing isinstance(v, (int, float)) to
isinstance(v, int | float) so the pre-commit ruff hook (which scans
the whole file) stops flagging UP038 on every commit that touches this
file.

Addresses #390 (do not auto-close — needs user confirmation in their
own environment after the next release).
2026-05-05 11:30:10 -07:00
chopratejas
7fe2d1e5b6 fix(ci): stage smoke-import script as host file (broke main post-#387)
The X1 smoke-import job (PR #387) embedded the smoke check as a
`<<PY ... PY` heredoc inside `bash -ec '...'`. The outer bash
single-quote preserves whitespace, so the heredoc body and the
closing `PY` retained their YAML indentation (column 14 inside
`bash -ec`). Bash never found a column-0 `PY` and read past EOF:

  bash: line 71: warning: here-document at line 65 delimited by
        end-of-file (wanted `PY')
  IndentationError: unexpected indent
  Process completed with exit code 1

Caught immediately on the post-merge release run on main,
manylinux_2_28_x86_64 / Python 3.11 (glibc 2.28-floor):
https://github.com/chopratejas/headroom/actions/runs/25361396712/job/74362427755

`python -c "<f-string>"` was the obvious next try but reintroduces
single-quote nesting (Python f-strings need quote chars; outer
`bash -ec '...'` cannot contain unescaped single quotes).

Fix: write the smoke script to `${RUNNER_TEMP}/smoke_import.py`
in a new "Stage smoke-import script" step (one heredoc at YAML
`run: |` level — uniform indent strip works fine). Linux job
mounts it via `-v ${RUNNER_TEMP}/smoke_import.py:/smoke_import.py:ro`
and runs `python /smoke_import.py`. macOS host runs the same
file directly. No quoting drift between paths.

Locally validated:
- actionlint clean
- 20/20 tests in test_release_workflows.py pass
- hand-execution of the heredoc + script roundtrip works

This is the second X1 follow-up after PR #387's shellcheck fix.
The original X1 design's gap: no PR-time release dry-run that
would have caught the heredoc on PR #387 itself. X2 (PR-time
dry-run) is the structural fix.
2026-05-04 23:52:52 -07:00
Tejas Chopra
4745219901
Merge pull request #387 from chopratejas/x1-smoke-import-wheels
fix(ci): X1 — smoke-import wheels on customer-representative envs before publish
2026-05-04 23:29:34 -07:00
chopratejas
3e78421267 fix(ci): replace ls with find in smoke-import wheel discovery
actionlint+shellcheck flagged the bash heredoc on the macOS host
step (release.yml:486) for SC2012 (use find instead of ls) and
SC2086 (quote variables to prevent globbing/word splitting). The
Linux container step had the same pattern but escaped detection
because actionlint can't reach into `docker run bash -ec '...'`.

Replace `ls .../headroom_ai-*-${py_tag}-${py_tag}-${arch_tag}.whl
| head -1` with `find ... -name "..." -print -quit` so the glob
expansion happens in find (handles non-alphanumeric filenames
correctly) and the variables sit inside a quoted -name argument
(no SC2086 trigger). Also replace the diagnostic `ls -la` calls
with portable find variants (-printf for GNU find on Linux,
-exec basename for BSD find on macOS).

Net behaviour identical: still picks one matching wheel (only one
exists per py_tag+arch_tag), still prints all wheels for diagnosis
on miss.
2026-05-04 23:02:28 -07:00
chopratejas
596212b428 fix(ci): smoke-import wheels on customer-representative envs before publish (X1)
Issue #355 plus the three follow-on hotfixes (#384/#385/#386) all
share a pattern: the wheel is technically valid (clippy passes,
tests pass, auditwheel is happy, the static-symbol audit added in
#384 is happy) but FAILS at runtime on a customer's box because of
a dynamic-link symbol mismatch. None of our pre-publish gates
actually `import headroom._core` on a representative customer
environment. They only build it.

What X1 adds
------------

A `smoke-import-wheels` job that runs after `build-wheels` and
before `publish-pypi` / `publish-docker` / `create-release`.

Matrix (6 jobs in parallel, ~3 min wall-clock):
- `manylinux_2_28_x86_64` + Python 3.11  (the floor we promise)
- `ubuntu:22.04` (glibc 2.35) + Python 3.12  (issue #355's env)
- `ubuntu:20.04` (glibc 2.31) + Python 3.10  (older LTS)
- `manylinux_2_28_aarch64` + Python 3.11  (aarch64 floor)
- `ubuntu:22.04` arm64 + Python 3.12  (aarch64 customer env)
- `macos-14` host + Python 3.13  (Apple Silicon)

Each job downloads its arch's wheel artifact, installs the wheel
matching its Python version inside the container, and runs the
exact command the proxy's `_check_rust_core` runs at startup:

    from headroom._core import hello as _rust_hello

If any matrix entry fails, `publish-pypi` / `publish-docker` /
`create-release` are blocked. The matrix tells us exactly which
customer environment combination breaks.

Regression test in tests/test_release_workflows.py:
`test_release_workflow_has_smoke_import_wheel_gate` pins the job's
existence, the required matrix entries, and — critically — the
gating wires (publish-pypi / publish-docker / create-release all
need-and-require-success on the smoke job). A future "this slow
CI step always passes anyway, drop it" refactor fails at PR time.

Companion tests `test_glibc_compat_shim_present_in_headroom_py`
and `test_release_workflow_audits_wheel_glibc_symbols` (added in
#384) cover the static-symbol gate; this PR is the dynamic-link
gate. Both are needed.
2026-05-04 22:48:55 -07:00
Tejas Chopra
39cb7c4dc4
Merge pull request #386 from chopratejas/hotfix-shim-link-arg
fix(ci): force-link glibc shim with -Wl,-u for aarch64
2026-05-04 22:25:53 -07:00
chopratejas
820e66cae6 fix(ci): force-link glibc shim with -Wl,-u so aarch64 wheel includes it
PR #385's shim works on x86_64 wheel build but FAILS audit on aarch64
in run 25358313722:

    FAIL: headroom_ai-0.20.27-cp310-cp310-manylinux_2_28_aarch64.whl
    references symbols above its glibc floor:
      __isoc23_strtoll (no version tag, introduced in glibc 2.38)

Diagnosis: cargo's link order on aarch64 happens to place our shim's
static archive BEFORE the ORT prebuilt archives. When the linker
scans our archive, no UND `__isoc23_*` exists yet (ORT hasn't been
scanned), so our shim's `.o` is dropped (no symbol to satisfy). ORT
scans next, registers UND, but our archive isn't rescanned.
Result: `_core.so` still has UND `__isoc23_*` symbols and the audit
rightly rejects the wheel.

On x86_64 the order happened to be the opposite (ORT first → UND
registered → our archive scans next → satisfies → pulled in).
Order is implementation-defined and clearly arch-dependent.

Fix: emit `cargo:rustc-link-arg=-Wl,-u,<sym>` for each `__isoc23_*`
symbol in `build.rs`. `-u <sym>` (a.k.a. `--undefined`) tells the
linker to treat the symbol as undefined at the START of linking,
which forces any archive defining it to be scanned and its members
pulled in regardless of relative archive order. Shim is now
uniformly linked on both x86_64 and aarch64.

Documented inline in `build.rs`. Standard workaround for the
static-library-link-order problem when the consumer scans after
the provider.
2026-05-04 22:24:39 -07:00
Tejas Chopra
af0a9604ee
Merge pull request #385 from chopratejas/hotfix-glibc-shim-no-alias
fix(ci): glibc shim — drop alias attribute, forward-declare strtol
2026-05-04 21:45:03 -07:00
chopratejas
6b15acc3b5 fix(ci): glibc shim — drop alias attribute, forward-declare strtol
PR #384 introduced glibc_compat.c using __attribute__((weak, alias("strtol"))) which fails to compile because GCC requires the alias TARGET to live in the same translation unit. strtol is in libc.so.6, not the .c file. Result: clippy fails on every Linux CI job for every PR + main:

  glibc_compat.c: error: '__isoc23_strtol' aliased to undefined symbol 'strtol'

Two-line architectural change: (1) drop the alias attribute, give each __isoc23_* function a plain body that calls the older strtol family; (2) forward-declare the older prototypes ourselves instead of #include <stdlib.h>, otherwise GCC's __REDIRECT_NTH(strtol -> __isoc23_strtol) would silently rewrite our delegation into an infinite recursion.

Symbol-resolution semantics unchanged: on glibc 2.38+, libc's strong __isoc23_strtoll preempts ours via global-scope-first lookup; on glibc < 2.38, ours wins. Either way the symbol resolves and import succeeds.

Both traps documented inline in glibc_compat.c so a future refactor doesn't reintroduce them. PR #384's commit message overstated the validation: I tested the audit script against the broken wheel but did NOT compile the shim itself before merging. Adding the X1 smoke-import gate (separate PR) is what would have caught this.
2026-05-04 21:43:33 -07:00
Tejas Chopra
6793adb003
Merge pull request #384 from chopratejas/fix-355-isoc23-shim
fix: ship glibc 2.38 compat shim + wheel symbol audit (closes #355)
2026-05-04 21:33:11 -07:00
chopratejas
e2146724af fix: ship glibc 2.38 compat shim + wheel symbol audit (closes #355)
Issue #355: published headroom_ai-*-manylinux_2_28_*.whl fails to import on Ubuntu 22.04, Debian 11/12, Conda envs with libc < 2.38: 'ImportError: undefined symbol: __isoc23_strtoll'.

Root cause: ORT prebuilt artifacts (downloaded via fastembed's ort-download-binaries-rustls-tls feature) are compiled with gcc-14.2.1 on a glibc-2.38+ host and reference __isoc23_strtoll. Our manylinux build host has glibc 2.38 so the link succeeds; end users with older glibc don't.

Two-part fix: (1) crates/headroom-py/glibc_compat.c provides weak-alias definitions for __isoc23_strtol/strtoll/strtoul/strtoull delegating to the older strtol* family, compiled by build.rs on Linux/glibc only. The dynamic linker prefers glibc's strong symbol when present (>= 2.38) and falls back to ours when not (< 2.38). (2) scripts/audit_wheel_glibc_symbols.py is a release.yml gate that runs objdump -T on every Linux wheel and rejects any UND symbol whose required glibc version exceeds the wheel's manylinux floor.

Validated: the audit correctly rejects the actually-broken v0.20.26 wheel with a precise diagnostic. The shim itself is a tiny static link with zero runtime cost.

Regression tests in tests/test_release_workflows.py pin the shim's load-bearing pieces (.c file, build.rs trigger, [build-dependencies] cc dep) and the audit invocation in release.yml. Future drift fails at PR time, not in the next release.

This is the same bug class as PR #371 (rustls-everywhere). Each instance gets fixed; the audit gate now catches the *class* — any future static linkage that introduces a post-floor symbol gets blocked before publish.
2026-05-04 21:31:19 -07:00
Tejas Chopra
31bd9f78e2
Merge pull request #382 from chopratejas/hotfix-sdist-os-mismatch
fix(ci): rebuild sdist on the renamed wheel-matrix host
2026-05-04 18:21:47 -07:00
chopratejas
75576dae2b fix(ci): rebuild sdist on the renamed wheel-matrix host
PR #376 pinned the wheel matrix's `os:` from `ubuntu-latest` to
`ubuntu-24.04` (explicit pinning, no semantic change). It silently
disabled the sdist build, whose conditional was

    if: matrix.os == 'ubuntu-latest' && matrix.target == 'x86_64-unknown-linux-gnu'

The literal `'ubuntu-latest'` no longer matched, sdist never built,
`release-assets/*.tar.gz` was empty, and `create-release` failed at

    gh release upload release-assets/*.tar.gz --clobber

with "no matches found". v0.20.22's release didn't get its sdist on
the GitHub Release.

Fix: key the conditional on `matrix.target` only. sdist is
platform-independent so any single matrix row is fine; using `target`
decouples the sdist build from any future host-runner rename.

Regression test in tests/test_release_workflows.py:
`test_sdist_build_conditional_keyed_on_target_not_os` pins the
target-only form. A future "let's add `os` back for clarity" refactor
will fail at PR time, not 8 minutes into a release.

This is a follow-up to PR #379 (docker bake `name=`) — same root
cause class: PR #376's matrix-shape changes silently broke a
downstream conditional whose literal value was tied to the OLD
matrix shape. Both fixes are now in place + pinned by tests.
2026-05-04 18:09:52 -07:00
chopratejas
573543fce7 fix: PR-E4 OpenAI prompt_cache_key auto-injection (Phase E)
OpenAI exposes `prompt_cache_key` to pin prefix-cache lookups to a
tenant-stable identity (preventing org-wide cache collisions). Most
clients don't set it. This PR auto-derives one from the request's
structural prefix `(model, system, tools)` and injects it on PAYG
OpenAI requests where the customer has not provided their own value.

Universal safety contract:
- Auth-mode gate: only AuthMode::Payg bodies are mutated. OAuth and
  Subscription requests pass through byte-equal (preserves Phase A
  passthrough invariant). Both gates emit `e4_skipped` events.
- Customer-set values win: `prompt_cache_key` already present →
  skip injection. Empty strings count as absent.
- Idempotent: same `(model, system, tools)` always derives the
  same key, so re-running yields identical bytes.

Key derivation: `hex(sha256(model || sha256(system) ||
sha256(tools)))[..32]` — 128 bits of collision resistance, 32 hex
chars on the wire. User/assistant message content is deliberately
excluded (they vary per turn; including would defeat caching).

Observability: every skip emits `e4_skipped` with a stable reason
(`auth_mode` / `key_present` / `not_an_object`); every successful
injection emits `e4_applied` with only the first 8 hex chars of the
key (full key is identifying material — never logged).

Hook point: `forward_http` in `crates/headroom-proxy/src/proxy.rs`,
between the live-zone dispatcher's body decision and the upstream
forward. Auth-mode is already classified at request entry.

Affects pre-existing dispatcher byte-fidelity tests (chat
completions, responses, responses streaming) — they previously
asserted byte-equality with no auth header (default PAYG). Updated
those tests to send an OAuth bearer so they keep their byte-equality
intent independent of E4. The E4 byte-mutation behaviour has its own
test matrix in `integration_e4_openai_cache_key.rs`.

Files added:
- `crates/headroom-proxy/src/cache_stabilization/openai_cache_key.rs`
- `crates/headroom-proxy/tests/integration_e4_openai_cache_key.rs`

Files modified:
- `crates/headroom-proxy/src/cache_stabilization/mod.rs` — `pub mod
  openai_cache_key;` (only shared file with parallel E1/E2/E3/E6 PRs)
- `crates/headroom-proxy/src/proxy.rs` — call site + helper
- `crates/headroom-proxy/Cargo.toml` — promote sha2 to runtime dep
- 3 integration test files — auth-mode plumbing for byte-equality
  invariants
2026-05-04 17:35:51 -07:00
Tejas Chopra
397c805698
Merge pull request #381 from chopratejas/realign-E3-anthropic-cache-control
fix: PR-E3 Anthropic cache_control auto-placement (Phase E)
2026-05-04 17:06:57 -07:00
chopratejas
8672d5c326 fix: PR-E3 Anthropic cache_control auto-placement (Phase E)
Auto-place a single ephemeral cache_control marker on the last tool

definition for PAYG-classified Anthropic requests when the customer

has not placed any markers. Hand-rolled SDK callers and smaller

agents (Aider/Continue/curl) get prompt-cache hits without learning

Anthropic's marker API.

Safety contract:

1. Auth-mode (caller-side, F1 classify): PAYG only. OAuth and subscription requests pass through byte-equal — mutating their bytes risks looking like cache-evasion to upstream.

2. Customer-placement-wins: walks system (array form), messages[].content (array form), and tools[] top-level. Any pre-existing marker -> skip with reason=marker_present.

3. Idempotency: re-running on a body that already has our marker falls into gate (2).

First-ship policy: place ONE marker on the last tool. The system/message-history/4th slots are documented but require production telemetry to enable.

Bedrock invoke + invoke-streaming hard-code OAuth so AWS SigV4-signed requests never get auto-placed (Bedrock is an IAM channel, not PAYG).

Observability: tracing::info! event=e3_applied / event=e3_skipped (reason in {auth_mode, marker_present}) so dashboards can confirm the gates fire as designed.

Files added: cache_stabilization/anthropic_cache_control.rs (module + 15 unit tests); tests/integration_e3_anthropic_cache_control.rs (5 integration tests covering all three gates).

Files modified: cache_stabilization/mod.rs; compression/live_zone_anthropic.rs (new auth_mode parameter on compress_anthropic_request); proxy.rs; bedrock/invoke.rs; bedrock/invoke_streaming.rs.
2026-05-04 16:34:04 -07:00
Tejas Chopra
d3c70acea5
Merge pull request #362 from chopratejas/realign-D4-vertex-native
fix: PR-D4 native Vertex publisher path + ADC
2026-05-04 16:28:27 -07:00
chopratejas
c10a2195af fix(proxy): PR-D4 native Vertex publisher path + ADC bearer auth
Adds a Rust-native Vertex AI publisher route ahead of the LiteLLM
Python converter (which dropped `thinking`, `redacted_thinking`,
`document`, `image`, `server_tool_use`, `mcp_tool_use` block kinds —
the P4-37 / P4-38 bug). After this PR the Vertex `:rawPredict` and
`:streamRawPredict` calls survive byte-equal upstream and benefit
from the live-zone Anthropic dispatcher (PR-B-series) running over
the body — same behaviour as `/v1/messages`.

New module `crates/headroom-proxy/src/vertex/`:

- `mod.rs` — single dispatch handler at the
  `/v1beta1/.../models/:model_action` route. Splits the trailing
  `:<verb>` segment with `str::rsplit_once(':')` (no regex) and
  flips an `attach_sse_tee` flag to dispatch to the streaming or
  non-streaming arm. Both verbs share one axum route shape because
  matchit can't distinguish two patterns that overlap on a
  parameter.
- `envelope.rs` — `VertexEnvelope` parser. Confirms
  `anthropic_version` present + `model` field absent (the two
  fingerprints of the Vertex envelope vs `/v1/messages`).
- `adc.rs` — `TokenSource` trait + `GcpAdcTokenSource` (production,
  `gcp_auth` 0.12) + `StaticTokenSource` (tests). Caches tokens
  with a 60s refresh-ahead-of-expiry window. Emits structured
  `event = "vertex_adc_token_refreshed"` per refresh.
- `raw_predict.rs` — POST handler + shared `forward_vertex_request`.
  Buffers body, parses envelope, runs live-zone Anthropic
  compression, fetches ADC bearer, attaches
  `Authorization: Bearer <token>` (overwrites client-supplied
  Authorization header), forwards. SSE telemetry tee for the
  streaming verb reuses PR-C1's `AnthropicStreamState` directly
  (Vertex streams plain SSE, unlike Bedrock's binary EventStream).
- `stream_raw_predict.rs` — module-level docs + alias to the
  shared dispatcher (the streaming-vs-non-streaming difference is
  one boolean flag inside the shared forwarder).

Modifications:

- `proxy.rs::build_app` — registers the single Vertex route.
- `proxy.rs::AppState` — new `vertex_token_source: Arc<dyn TokenSource>`
  field. Production constructs `GcpAdcTokenSource` lazily (no GCP
  call until first `bearer()`); tests inject `StaticTokenSource`
  via the new `AppState::with_token_source` helper.
- `config.rs` — adds `--vertex-region` / `HEADROOM_PROXY_VERTEX_REGION`
  (default `us-central1`, observability tag only — the upstream URL
  is `--upstream`) and `--vertex-adc-scope` /
  `HEADROOM_PROXY_VERTEX_ADC_SCOPE` (default `cloud-platform`).
- `Cargo.toml` (workspace + proxy) — adds `gcp_auth = "0.12"` and
  `async-trait = "0.1"`.
- `tests/common/mod.rs` — `start_proxy_with_state` accepts both
  config + state customizers; `install_static_token_source` helper
  for tests.

`crates/headroom-proxy/tests/integration_vertex_raw_predict.rs` —
all five tests pass:

1. `native_envelope_round_trip_byte_equal` — Vertex-shape body
   (with `anthropic_version`, no `model`) round-trips SHA-256
   byte-equal upstream.
2. `adc_bearer_token_signed_correctly` — `Authorization: Bearer
   <static-test-token>` reaches upstream verbatim and OVERWRITES a
   client-supplied Authorization header.
3. `thinking_block_preserved` — request with `thinking` (incl.
   signature) + `redacted_thinking` (incl. opaque `data`) blocks
   round-trips byte-equal even with `LiveZone` compression mode
   enabled. This is the P4-37 / P4-38 teeth.
4. `stream_raw_predict_sse_handled` — `:streamRawPredict` proxies
   an Anthropic SSE response (full `message_start` →
   `content_block_delta` → `message_stop` sequence) back to the
   client without corruption; SSE content-type preserved end-to-end;
   bearer attached.
5. (bonus, no-silent-fallback contract)
   `adc_failure_returns_5xx_no_silent_forward` — when the token
   source returns `Err`, the proxy returns 5xx and never reaches
   upstream. Verifies the `event = "vertex_adc_fetch_failed"`
   error path.

Workspace: `cargo test --workspace` green; `cargo clippy --workspace
-- -D warnings` clean; `make ci-precheck` passes.

- No silent fallbacks: ADC failure → structured 5xx, never an
  unauthenticated forward.
- No hardcodes: every knob (region, ADC scope, upstream URL) is
  CLI-flag + env-var configurable.
- No regexes: axum path parameters + `str::rsplit_once` only.
- Comprehensive structured logs: `event` field on every decision
  point — `vertex_envelope_parsed`, `vertex_envelope_invalid`,
  `vertex_compression_skipped`, `vertex_compression_applied`,
  `vertex_adc_token_refreshed`, `vertex_adc_fetch_failed`,
  `vertex_streaming_pipeline_active`, `vertex_sse_stream_closed`,
  `vertex_forwarded`, `vertex_unknown_verb`, etc.
- Performant: no body clone; ADC token cached + refreshed
  ahead-of-expiry, not fetched per request.
- Comprehensive tests: realistic Anthropic block content
  (signature payload, redacted_thinking opaque blob) in
  `thinking_block_preserved`.

The local `gcloud auth application-default print-access-token`
returns no credentials, so manual validation against a real Vertex
endpoint is not possible in this PR. Follow-up: the user runs
`gcloud auth application-default login` once and exercises a live
Vertex request — should be a no-code-change check.

PR-D1 (Bedrock native) is running concurrently and will land its
own envelope module at `crates/headroom-proxy/src/bedrock/envelope.rs`.
The two envelope modules are intentionally siblings (not a shared
trait) — the shapes differ (Bedrock has a different
`anthropic_version` value, no `model` field, AWS SigV4 instead of
GCP ADC), and a premature shared abstraction would obscure the
provider-specific contracts. Whichever PR merges second rebases
without conflict.

Retires P4-38 (and the Vertex parts of P4-39); marketplace BYOC
pitch (per project memory) gets one more native provider.
2026-05-04 16:24:38 -07:00
Tejas Chopra
41ca0544c7
Merge pull request #378 from chopratejas/realign-E6-cache-drift-telemetry
fix: PR-E6 cache-bust drift detector telemetry (Phase E)
2026-05-04 16:10:56 -07:00
chopratejas
ce37940d17 fix: PR-E6 cache-bust drift detector telemetry (Phase E)
Per-session SHA-256 fingerprint of the cache hot zone (system / tools /
first 3 messages) with structured-log emission on drift. Detector is
read-only: never mutates request bytes, preserves the Phase A
passthrough invariant. Surfaces invisible cache busts (system prompt
edited mid-session, tools reshuffled, early message changed) without
rewriting them.

* crates/headroom-proxy/src/cache_stabilization/drift_detector.rs:
  StructuralHash (system, tools, early_messages digests),
  compute_structural_hash, observe_drift, derive_session_key,
  DriftState (LRU bounded to 1000 sessions in production).
* Session keys derive from Authorization / x-api-key / client IP /
  (IP, user-agent). Bearer tokens and API keys are SHA-256 hashed
  before they ever reach the log line; the raw secret is never logged.
* Wired into forward_http after the body is buffered, before the
  compression dispatcher runs. Skips paths whose wire shape is not
  Anthropic / OpenAI Chat / OpenAI Responses.
* AppState gains drift_state: DriftState. Bedrock unit-test
  literal-construction sites updated.
* 14 unit tests + 1 integration test covering first-request,
  no-drift, per-dimension drift, multi-dim drift, LRU eviction,
  non-mutation invariant, and bearer-token-never-logged.

Adds lru = "0.12" and promotes sha2 = "0.10" to a normal dependency
on headroom-proxy.
2026-05-04 14:54:20 -07:00
Tejas Chopra
c81755c965
Merge pull request #379 from chopratejas/hotfix-docker-bake-name
fix(ci): docker per-arch bake needs explicit image name in output
2026-05-04 14:46:19 -07:00
Tejas Chopra
d1b836d78c
Merge pull request #377 from chopratejas/realign-E5-volatile-detector
fix: PR-E5 volatile-content detector + customer warning (Phase E)
2026-05-04 14:37:54 -07:00
chopratejas
d8aae382b0 fix: PR-E5 volatile-content detector + customer warning (Phase E)
Adds an observation-only detector that scans inbound LLM request
bodies for content that busts prompt-cache hits and emits one
structured WARN log per finding. Strictly read-only — never
mutates the request, the Phase A bytes-in==bytes-out invariant
still holds.

Detection patterns (no regex, per Realignment build constraints):
- ISO-8601 timestamps (byte-position check at 4=- 7=- 10=T 13=: 16=:)
- UUID v4 (36 chars, hyphens at 8/13/18/23, version nibble 4 at pos 14,
  RFC 4122 §4.4 variant nibble at pos 19)
- ID-named JSON keys (request_id / trace_id / session_id /
  correlation_id) with non-empty values

Scope:
- Anthropic body shape: system, messages[].content (string or blocks),
  tools[].description, tools[].input_schema (recursive).
- OpenAI body shape: messages[].content, tools[].function.description,
  tools[].function.parameters (recursive).
- Other paths (Bedrock / Vertex etc.) deferred to Phase E follow-up.

Findings capped at 10 per request; sample truncated to 80 bytes
(UTF-8 boundary safe) to avoid logging bulk customer data.

Files:
- New crates/headroom-proxy/src/cache_stabilization/{mod,volatile_detector}.rs
- crates/headroom-proxy/src/lib.rs: pub mod cache_stabilization
- crates/headroom-proxy/src/proxy.rs: detector hook in the
  buffered-body branch, before the compression dispatcher
- New tests/integration_volatile_detector.rs (subscriber capture
  asserts the WARN line + byte-equal upstream body)

12 unit tests (timestamp, UUID, ID-field nesting, stable content,
cap, non-mutation, ApiKind shape isolation, empty-ID-value guard,
RFC 3339 space separator, non-v4 UUID rejection, UTF-8 truncate
safety, endpoint mapping) and 1 integration test (warn emitted
+ upstream body byte-equal). cargo fmt / clippy --workspace -D
warnings / workspace test all pass; full make ci-precheck green.
2026-05-04 12:40:27 -07:00
chopratejas
8f6bc5865c fix(ci): docker per-arch bake needs explicit image name in output
PR #376's per-arch fan-out correctly removed `bake-file-tags` from
the docker-build step (tags belong on the multi-arch manifest, not
on per-arch images). But that left bake without ANY reference for
the push target — no tags AND no explicit `name=` in the output
spec. Every release-time docker-build job failed with the
misleading message:

    ERROR: tag is needed when pushing to registry

Buildx's actual constraint is "no tags AND no `name=` in the
output = no push target." Since push-by-digest discards tags
anyway, the fix is to specify `name=<registry>/<image>` directly
in the `*.output` spec. Tags are still applied later, only on the
multi-arch manifest by `docker-manifest`.

Regression test in tests/test_release_workflows.py:
`test_docker_per_arch_build_specifies_image_name_in_output`
pins the `name=` substring so a future "the labels block already
has the registry, surely buildx can figure it out" refactor will
fail at PR time rather than 2 minutes into release.
2026-05-04 12:26:46 -07:00
Tejas Chopra
0c46c7e010
Merge pull request #368 from chopratejas/realign-D3-bedrock-observability
fix: PR-D3 Bedrock observability + auth-mode integration (Phase D close)
2026-05-04 12:04:23 -07:00
chopratejas
90ef66213d fix(proxy): PR-D3 Bedrock observability + auth-mode integration
Phase D close. Adds the operator-facing observability surface that
PRs D1 (native invoke) and D2 (streaming EventStream) deferred, and
wires the Phase F PR-F1 auth-mode classifier into the Bedrock route
so downstream cache/compression policy gates have something to read.

Changes
-------

* New `bedrock::auth_mode_layer` middleware. Classifies every
  inbound Bedrock request via F1's `classify`, coerces the result
  to `AuthMode::OAuth` per the Bedrock policy matrix (SigV4 IAM is
  OAuth-equivalent), and stores the resolved value in
  `request.extensions()` so PR-F2/F3 can read it without
  re-classifying. Mismatches are logged at WARN with
  `event=bedrock_auth_mode_unexpected` — no silent coercion.

* New `observability` module with three Prometheus families:
    - `bedrock_invoke_count_total{model, region, auth_mode}` (counter)
    - `bedrock_invoke_latency_seconds{model, region}` (histogram)
    - `bedrock_eventstream_message_count_total{model, region, event_type}`
      (counter)
  Registered lazily via `OnceLock` so per-request work is just
  `inc_with_label_values` / `observe`. Latency observed via an
  RAII `LatencyGuard` so every error path is instrumented; a
  future regression that adds a new return path can't drop the
  observation.

* New `GET /metrics` endpoint serves the registry in Prometheus
  text format. Mounted unconditionally — no feature flag gate — so
  scrape works regardless of which provider routes are mounted.

* Bedrock invoke + invoke-streaming handlers now extract
  `Extension<AuthMode>`, log it in their entry breadcrumbs
  (`event=bedrock_invoke_received`, `event=bedrock_invoke_streaming_received`),
  and pass `model`/`region` into `translate_stream` so per-message
  metrics carry the right labels.

* Operator docs at `docs/bedrock.md`: AWS credential chain,
  region/endpoint config, supported model IDs (`anthropic.*`
  literal-match — no regexes), compression behaviour, sample
  PromQL queries, structured-log correlation, rollback path.

Tests added (6, all green)
--------------------------

Auth-mode (`integration_bedrock_authmode.rs`):
  1. `bedrock_classified_as_oauth` — empty headers → OAuth in
     extensions.
  2. `oauth_policy_passthrough_prefer` — body byte-equal upstream;
     no auto cache_control / prompt_cache_key injected.

Metrics (`integration_bedrock_metrics.rs`):
  3. `metrics_increment_per_invoke` — 3 invokes → counter=3 with
     correct labels.
  4. `metrics_observe_latency` — 1 invoke → histogram count=1,
     sum>0.
  5. `eventstream_metrics_per_message_type` — 5 chunks → counter=5
     with `event_type=chunk`.
  6. `metrics_endpoint_serves_scrape` — `/metrics` returns 200,
     `text/plain`, all three metric families' HELP/TYPE lines
     present.

Each metrics test owns a unique (model, region) tuple so the
global `prometheus` registry — shared across parallel tests in
the same binary — gives each test isolated label rows. Without
isolation, parallel tests cross-contaminate counters.

Constraints honoured
--------------------

* No silent fallbacks — auth-mode coercion is logged at WARN.
* No hardcodes — region from `--bedrock-region`, model from axum
  path parameter.
* No regexes — vendor prefix is literal `anthropic.`.
* Comprehensive structured logs — every metric increment paired
  with `tracing::debug!` carrying the same labels for incident
  correlation.
* Performant — `OnceLock`-cached descriptors, RAII guard, total
  D3 overhead well under 1us per request.
* Cardinality bounded — labels driven by config + bounded enums,
  never by user-controlled bytes.

Live cloud validation deferred
------------------------------

The wiremock-backed integration tests are the canonical correctness
gate for D3. A real Bedrock smoke test requires `bedrock:InvokeModel`
permissions in the developer's AWS account and is documented in
`docs/bedrock.md` — both D1 and D2 hit sandbox permission issues
trying this path; D3 follows the same convention.

Stacked on
----------

PR #364 (D1 native invoke), PR #365 (D2 streaming EventStream),
PR #366 (F1 classifier helper). Merge those first; this PR will be
rebased onto main once they land.
2026-05-04 11:07:47 -07:00
Tejas Chopra
7350e07bea
Merge pull request #376 from chopratejas/native-arm64-runners
ci: native arm64 runners — drop QEMU, cut wheel + docker build time
2026-05-04 10:19:09 -07:00
chopratejas
ed36676c9c ci: native arm64 runners — drop QEMU, cut wheel + docker build time
GitHub-hosted Linux arm64 runners (`ubuntu-24.04-arm`) went GA in Aug
2025 and are free for public repositories. Switching the aarch64
wheel + the multi-arch docker matrix off `ubuntu-latest`+QEMU onto
the native runner cuts wall-clock on both surfaces.

release.yml — build-wheels matrix
  * `aarch64-unknown-linux-gnu`: `ubuntu-latest` → `ubuntu-24.04-arm`.
    maturin-action still runs inside `quay.io/pypa/manylinux_2_28_aarch64`,
    but the container now executes natively on an aarch64 kernel
    instead of through QEMU emulation. Aarch64 wheel build drops
    from ~50–60 min to ~10 min.
  * `x86_64-unknown-linux-gnu`: `ubuntu-latest` → `ubuntu-24.04`
    (pin the moving alias for reproducibility; no semantic change).

docker.yml — fan-out + manifest merge
  * Pre-#377: one `docker-variant-tags` matrix job per variant on
    `ubuntu-latest`, using bake's `platforms = [amd64, arm64]` with
    QEMU for the arm64 leg. ~1h per variant, 8 variants.
  * Post-#377: split into `docker-build` (variant × arch = 16
    parallel jobs, each on its native runner, single-platform
    push-by-digest) and `docker-manifest` (per variant, merges the
    two arch digests into a multi-arch tagged manifest with
    `docker buildx imagetools create`, signs the index manifest
    with cosign). Wall-clock drops from ~1h per variant to ~10 min.
  * `docker/setup-qemu-action` removed — there's no QEMU left.
  * Per-(variant, arch) GHA cache scopes so the two arches don't
    collide on cache keys.
  * `promote-latest` rewired to depend on `docker-manifest`.

Behavior change: cosign now signs only the multi-arch index digest
per variant, not each per-platform image. `cosign verify <repo>:tag`
(the typical flow) is unchanged because cosign resolves the tag to
the index digest. Verifiers pinning a specific per-arch digest will
need to verify the index digest instead.

Regression tests in tests/test_release_workflows.py:
  * `test_aarch64_wheel_uses_native_arm64_runner` — pins the
    aarch64 row to `ubuntu-24.04-arm` (and the amd64 row to
    `ubuntu-24.04`, not `-latest`), so a future "let me unify on
    ubuntu-latest" refactor surfaces the QEMU regression at PR time.
  * `test_docker_workflow_builds_on_native_arch_runners` — pins the
    fan-out matrix's arch entries, asserts push-by-digest, asserts
    `setup-qemu-action` is absent from non-comment lines, asserts
    the manifest-merge job exists.

Verified:
  * Both workflow files parse as valid YAML with the expected job
    graph (`docker-build` → `docker-manifest` → `promote-latest`,
    16 fan-out jobs, 8 manifest jobs).
  * `docker buildx imagetools inspect <tag> --format '{{ json . }}'`
    exposes the index digest at `.manifest.digest` (confirmed via
    Docker's official reference).
  * `ubuntu-24.04-arm` is the correct GitHub-hosted runner label
    (GA 2025-08-07, free for public repos).
  * `make ci-precheck-rust` and `make ci-precheck-python` both pass
    locally; `tests/test_release_workflows.py` is 15/15 green
    (13 existing + 2 new).
2026-05-04 09:37:28 -07:00
Tejas Chopra
0b77955ecb
Merge pull request #374 from chopratejas/fix-281-subscription-display-time-synthesis
fix: PR #281 — synthesize 5h subscription window after Anthropic reset (no extra polling)
2026-05-04 08:50:58 -07:00
Tejas Chopra
486372b644
Merge pull request #375 from chopratejas/fix-372-image-extra-py313
fix: PR #372 — restore [image] extra on Python 3.13 via rapidocr 3.x adapter
2026-05-04 08:50:49 -07:00
chopratejas
b154e17853 fix: PR #372 — restore [image] extra on Python 3.13 via rapidocr 3.x adapter
Root cause: `headroom-ai[all]==0.20.16` fails to install on Python 3.13
because `rapidocr-onnxruntime` 1.4.0–1.4.4 wheels declare
`requires-python: <3.13,>=3.6`. After 1.4.x the rapidocr ecosystem
split: `rapidocr-onnxruntime` (bundled-ORT, capped at <3.13) vs
`rapidocr` 3.x (engine-agnostic core, supports 3.13+, returns
RapidOCROutput dataclass instead of v1's tuple).

Fix:

1. pyproject.toml — environment-marker hybrid in [image]:
   - rapidocr-onnxruntime>=1.4.0,<2; python_version<'3.13'
   - rapidocr>=3.0,<4; python_version>='3.13'
   - onnxruntime>=1.7,<2; python_version>='3.13'
   ORT remains the engine on every Python version; bundle and speed
   unchanged, just split into two packages on 3.13+.

2. headroom/image/compressor.py — runtime adapter:
   _resolve_rapidocr() tries v1 first, falls back to v3 when v1 is
   missing, returns (None, None) when neither installed. Cached at
   module scope. Detection at runtime (not Python-version-based) so
   users can install either package on any Python version.

   _ocr_extract branches on resolved api_version:
   - v1: (list[(box, text, score)], elapsed) tuple — unchanged
   - v3: RapidOCROutput dataclass with .txts / .scores / .boxes
     attrs (each may be None when nothing detected)

   Defensive None-handling, length-mismatch detection, structured
   log events for both branches.

Smoke test (real install verified before commit):
    pip install rapidocr onnxruntime pillow
    → result type: RapidOCROutput
    → fields: txts (None when empty), scores (None when empty), boxes
    Confirms the v3 None-coercion is necessary.

Tests: 11 new unit tests in tests/test_image_ocr_api_compat.py covering:
- Resolver: v1 preferred, v3 fallback, both missing
- v1 path: tuple parses, low-confidence None, empty result None
- v3 path: dataclass parses, low-confidence None, None attrs handled,
  mismatched lengths logged + None
- Backend missing: returns None gracefully

All 11 pass; `make ci-precheck` PASSED.

Closes #372.
2026-05-04 08:20:01 -07:00