2026-04-23 08:33:29 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
fix(ccr): verify a scanned marker's hash before advertising it (#2908)
## Description
`CCRToolInjector.scan_for_markers()` decides whether a compression
marker is Headroom's own by *shape* alone — any bracket marker carrying
a 24-hex hash counts, per the generic fallback pattern
(`\[.*?compressed.*?hash=([a-f0-9]{24})\]`). Other context tools emit
exactly that shape. Once a foreign hash is scanned,
`has_compressed_content` flips true and the retrieve tool + "Available
hashes" system instruction get injected for a hash this proxy never
stored — the model calls `headroom_retrieve`, gets a guaranteed miss,
and re-does work it already had. Two wasted turns per adopted foreign
hash.
Closes #2836
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/ccr/tool_injection.py`: added
`CCRToolInjector.verify_ownership()` — filters `detected_hashes` down to
hashes the compression store actually recognizes, via the same
`store.exists()` check the retrieve endpoint itself performs. Added a
small `_HashOwnershipStore` Protocol (structural typing, not a hard
dependency on the concrete `CompressionStore` class) and a
`compression_store` constructor field for dependency injection/testing.
`scan_for_markers()` itself is untouched — kept store-independent (pure
regex) rather than baking the check into the scan loop, since that
approach broke 24 existing tests that correctly test "does this shape
match" in isolation.
- `headroom/proxy/handlers/anthropic.py`,
`headroom/proxy/handlers/openai.py`: call `injector.verify_ownership()`
right after `scan_for_markers()` — the two real per-request call sites.
- `verify_ownership()` is also called inside `process_request()` (the
convenience wrapper `batch.py`'s Google path uses), so that path is
covered without a separate call site edit.
- `tests/test_ccr_tool_injection.py`: new `TestVerifyOwnership` class (6
tests) — the exact issue repro, a real-hash-survives case, mixed
own/foreign hashes, explicit store override, store-exception safety
(must not raise), and no-op-on-empty-hashes.
- `tests/test_proxy_anthropic_cache_stability.py`: 3 pre-existing tests
needed updating for the new (correct) behavior — two `_FakeInjector`
test doubles needed a `verify_ownership()` stub added, and one real
end-to-end test needed a genuine store entry seeded (via
`explicit_hash`) for the hash its hand-typed marker references, instead
of asserting on an unverified shape-only match.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — not run locally, will
confirm via CI
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)
### Test Output
```text
$ .venv/Scripts/python -m pytest tests/test_ccr_marker_policy.py tests/test_ccr_tool_always_on.py tests/test_ccr_tool_injection.py tests/test_proxy/test_ccr_frozen_prefix_coupling.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_handlers_batch.py tests/test_proxy_handler_helpers.py -q
151 passed in 15.87s
$ .venv/Scripts/python -m pytest tests/test_proxy_ccr.py tests/test_proxy_openai_responses_stream_ccr.py tests/test_anthropic_ccr_workspace_unbound.py tests/test_compression_store.py tests/test_no_ccr_lossy.py tests/test_proxy_handlers_batch.py -q
121 passed in 20.73s
$ .venv/Scripts/ruff check . && .venv/Scripts/ruff format --check . # touched files only
All checks passed / already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.14.5, local venv
- Exact command / steps: ran the issue's exact 3-line repro
(`CCRToolInjector.scan_for_markers()` on the foreign marker text, then
`verify_ownership()`) before and after the fix; separately verified a
genuinely-Headroom-stored hash (via `store.store(...,
explicit_hash=...)`) still survives verification and still drives
injection
- Observed result: before the fix (scan only, no verify step exists yet)
`has_compressed_content` is `True` for the foreign marker — matches the
bug report exactly. After adding `verify_ownership()`: foreign marker →
`detected_hashes == []`, `has_compressed_content is False`; real stored
hash → `detected_hashes == [real_hash]`, `has_compressed_content is
True`.
- Not tested: have not driven this through a live two-context-tool proxy
session (e.g. Headroom alongside another CCR-shaped tool in the same
conversation) — verified at the unit/integration level (the exact repro
plus the real proxy handler call sites via
`test_proxy_anthropic_cache_stability.py`'s end-to-end `TestClient`
tests), not via a live multi-tool session.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
internal CCR safety behavior, no user-facing docs reference the
marker-adoption mechanism)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable (release-please
generates this automatically from commit messages)
## Additional Notes
Design note on why `verify_ownership()` is a separate step rather than
baked into `scan_for_markers()`: my first attempt did exactly that and
broke 24 tests across `test_ccr_tool_injection.py`,
`test_ccr_marker_policy.py`, `test_proxy_anthropic_cache_stability.py`,
and `test_proxy_handler_helpers.py` — all of them legitimately testing
"does the regex detect this marker shape" independent of any store
state. Keeping the scan pure and adding an explicit, separately-testable
verification step kept that test surface intact while still closing the
real gap at the three places that actually decide whether to advertise
the retrieve tool.
2026-08-13 22:16:21 +05:30
|
|
|
from headroom.cache.compression_store import (
|
|
|
|
|
CompressionEntry,
|
|
|
|
|
get_compression_store,
|
|
|
|
|
reset_compression_store,
|
|
|
|
|
)
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
from headroom.ccr import response_handler as response_handler_module
|
2026-04-23 08:33:29 -05:00
|
|
|
from headroom.proxy.handlers import batch as batch_module
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
from headroom.proxy.handlers import gemini as gemini_module
|
fix(proxy/gemini): preserve non-text content across the compression round-trip (#2079)
## Description
Two related content-loss bugs in the Gemini `contents[]` <->
`messages[]` compression round-trip.
Both drop or misplace real user content that entries with **non-text**
parts should carry through
untouched. They share the same theme (non-text preservation), so they're
bundled here as two
commits.
### 1. Google batch handler restores preserved entries by the wrong
index (`handlers/batch.py`)
The `batchGenerateContent` handler restored preserved (non-text) entries
with the raw-index loop
that commit #836 (`_rebuild_gemini_contents`) replaced in the three
non-batch Gemini handlers:
```python
for orig_idx, original_content in preserved_contents.items():
if orig_idx < len(optimized_contents):
optimized_contents[orig_idx] = original_content
```
`preserved_indices` are indices into the **original** `contents[]`, but
`optimized_contents` is a
**shorter** list (text-less entries produce no message). Indexing
`optimized_contents` by
`orig_idx` overwrites the wrong entry and drops any preserved entry
whose original index is past
the optimized length. For:
```python
[user text, model functionCall, user functionResponse, model text]
```
the batch was forwarded to Google as **two** entries: the model's answer
overwritten by the
functionCall, and the functionResponse dropped. Unlike `gemini.py` there
is no
`if optimized_messages != messages` gate, so it runs on every mixed
batch item.
**Fix:** use the shared `_rebuild_gemini_contents` interleaving helper.
### 2. Code-execution parts not detected as non-text
(`handlers/gemini.py`)
`_has_non_text_parts` only recognized
`inlineData`/`fileData`/`functionCall`/`functionResponse`.
Gemini's code-execution feature emits `executableCode` and
`codeExecutionResult` parts, echoed
back in `contents[]` on later turns. Because they weren't detected:
- a mixed `text`+`executableCode` entry lost its code payload (only the
text survived the round-trip);
- a text-less `executableCode`+`codeExecutionResult` entry was treated
as a phantom in
`_rebuild_gemini_contents` — it consumed the next optimized message,
dropping the whole code turn
and shifting a following user turn into the model's role slot
(corrupting role alternation).
**Fix:** add both keys to the non-text detection so those entries are
preserved verbatim.
Closes: no issue filed — both found while auditing the Gemini
contents<->messages round-trip.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/batch.py`: use `_rebuild_gemini_contents`
instead of the raw-index restore loop.
- `headroom/proxy/handlers/gemini.py`: recognize `executableCode` /
`codeExecutionResult` in `_has_non_text_parts`.
- `tests/test_proxy_handlers_batch.py`: add
`test_handle_google_batch_create_preserves_functioncall_response_order`,
driving the handler with the **real** Gemini converters (the existing
batch tests stub them, which hid the bug); mix `GeminiHandlerMixin` into
the shared `DummyBatchHandler` so `_rebuild_gemini_contents` is
available.
- `tests/test_google_multimodal.py`: extend the parametrized
`test_each_non_text_key_detected` to the two new keys, and add
`test_code_execution_entry_survives`.
## Testing
- [x] New regression tests added (`tests/test_proxy_handlers_batch.py`,
`tests/test_google_multimodal.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py headroom/proxy/handlers/gemini.py \
tests/test_proxy_handlers_batch.py tests/test_google_multimodal.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the
interleaving/detection with dependency-free scripts (replicating the
Gemini converters, the old loop, and `_rebuild_gemini_contents`) and
left the full pytest to CI.
- Exact command / steps: ran two standalone scripts. Script 1 rebuilds a
Gemini batch request with `preserved_indices` holding a
`functionCall`/`functionResponse` pair and compares the old raw-index
loop against `_rebuild_gemini_contents`. Script 2 feeds a
`codeExecutionResult` entry through `_has_non_text_parts` and the
preserve path with and without the two new allowlist keys. Also ran `uvx
ruff@0.15.17 check` on the changed files and tests.
- Observed result: the old batch loop drops the `functionResponse` and
overwrites the answer (4 parts collapse to 2);
`_rebuild_gemini_contents` keeps all 4. Without the new keys the
code-execution entry is dropped/shifted (2 parts, code absent); with
them it survives intact (3 parts, code present). Lint clean. See the two
blocks below.
Batch fix (bug #1):
```text
preserved_indices: [1, 2]
OLD result parts: ['text', 'functionCall'] len 2
NEW result parts: ['text', 'functionCall', 'functionResponse', 'text'] len 4
GEMINI BATCH REBUILD FIX VERIFIED (old drops response + overwrites answer; new keeps all 4)
```
Code-execution fix (bug #2):
```text
(b) OLD len=2 NEW len=3
(a) OLD has code=False NEW has code=True
GEMINI CODE-EXECUTION PRESERVE FIX VERIFIED (old drops/shifts; new keeps intact)
```
- Not tested: a live Google/Gemini round-trip (handlers stubbed, as the
existing tests do). Full local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + standalone logic checks; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Two small behavioral changes (one loop -> shared helper, two keys
added to an allowlist) plus regression tests; no new dependencies. Both
complete/extend the non-text preservation the non-batch handlers already
do (the #836 line).
- @JerrettDavis tagging you since you reviewed the recent Gemini fixes.
Both of these drop content (functionResponse/images on batch;
code-execution on the normal round-trip), so they seemed worth surfacing
together. Thanks.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 19:16:35 +05:30
|
|
|
from headroom.proxy.handlers.gemini import GeminiHandlerMixin
|
2026-04-23 08:33:29 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeResponse:
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
*,
|
|
|
|
|
status_code: int = 200,
|
|
|
|
|
content: bytes = b"{}",
|
|
|
|
|
headers: dict[str, str] | None = None,
|
|
|
|
|
text: str | None = None,
|
|
|
|
|
json_data=None, # noqa: ANN001
|
|
|
|
|
) -> None:
|
|
|
|
|
self.status_code = status_code
|
|
|
|
|
self.content = content
|
|
|
|
|
self.headers = headers or {}
|
|
|
|
|
self.text = text if text is not None else content.decode("utf-8", errors="ignore")
|
|
|
|
|
self._json_data = json_data
|
|
|
|
|
|
|
|
|
|
def json(self): # noqa: ANN201
|
|
|
|
|
if self._json_data is not None:
|
|
|
|
|
return self._json_data
|
|
|
|
|
return json.loads(self.text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeHttpClient:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.posts: list[dict[str, object]] = []
|
|
|
|
|
self.gets: list[dict[str, object]] = []
|
|
|
|
|
self.requests: list[dict[str, object]] = []
|
|
|
|
|
self.post_response = FakeResponse()
|
|
|
|
|
self.get_response = FakeResponse()
|
|
|
|
|
self.raise_post: Exception | None = None
|
|
|
|
|
self.raise_get: Exception | None = None
|
|
|
|
|
|
|
|
|
|
async def post(self, url: str, **kwargs): # noqa: ANN003, ANN201
|
|
|
|
|
self.posts.append({"url": url, **kwargs})
|
|
|
|
|
if self.raise_post is not None:
|
|
|
|
|
raise self.raise_post
|
|
|
|
|
return self.post_response
|
|
|
|
|
|
|
|
|
|
async def get(self, url: str, **kwargs): # noqa: ANN003, ANN201
|
|
|
|
|
self.gets.append({"url": url, **kwargs})
|
|
|
|
|
if self.raise_get is not None:
|
|
|
|
|
raise self.raise_get
|
|
|
|
|
return self.get_response
|
|
|
|
|
|
|
|
|
|
async def request(self, method: str, url: str, **kwargs): # noqa: ANN003, ANN201
|
|
|
|
|
self.requests.append({"method": method, "url": url, **kwargs})
|
|
|
|
|
if self.raise_get is not None:
|
|
|
|
|
raise self.raise_get
|
|
|
|
|
return self.get_response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeMetrics:
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.record_calls: list[dict[str, object]] = []
|
|
|
|
|
self.failed_calls: list[dict[str, object]] = []
|
|
|
|
|
|
|
|
|
|
async def record_request(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.record_calls.append(kwargs)
|
|
|
|
|
|
|
|
|
|
async def record_failed(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.failed_calls.append(kwargs)
|
|
|
|
|
|
|
|
|
|
|
fix(proxy/gemini): preserve non-text content across the compression round-trip (#2079)
## Description
Two related content-loss bugs in the Gemini `contents[]` <->
`messages[]` compression round-trip.
Both drop or misplace real user content that entries with **non-text**
parts should carry through
untouched. They share the same theme (non-text preservation), so they're
bundled here as two
commits.
### 1. Google batch handler restores preserved entries by the wrong
index (`handlers/batch.py`)
The `batchGenerateContent` handler restored preserved (non-text) entries
with the raw-index loop
that commit #836 (`_rebuild_gemini_contents`) replaced in the three
non-batch Gemini handlers:
```python
for orig_idx, original_content in preserved_contents.items():
if orig_idx < len(optimized_contents):
optimized_contents[orig_idx] = original_content
```
`preserved_indices` are indices into the **original** `contents[]`, but
`optimized_contents` is a
**shorter** list (text-less entries produce no message). Indexing
`optimized_contents` by
`orig_idx` overwrites the wrong entry and drops any preserved entry
whose original index is past
the optimized length. For:
```python
[user text, model functionCall, user functionResponse, model text]
```
the batch was forwarded to Google as **two** entries: the model's answer
overwritten by the
functionCall, and the functionResponse dropped. Unlike `gemini.py` there
is no
`if optimized_messages != messages` gate, so it runs on every mixed
batch item.
**Fix:** use the shared `_rebuild_gemini_contents` interleaving helper.
### 2. Code-execution parts not detected as non-text
(`handlers/gemini.py`)
`_has_non_text_parts` only recognized
`inlineData`/`fileData`/`functionCall`/`functionResponse`.
Gemini's code-execution feature emits `executableCode` and
`codeExecutionResult` parts, echoed
back in `contents[]` on later turns. Because they weren't detected:
- a mixed `text`+`executableCode` entry lost its code payload (only the
text survived the round-trip);
- a text-less `executableCode`+`codeExecutionResult` entry was treated
as a phantom in
`_rebuild_gemini_contents` — it consumed the next optimized message,
dropping the whole code turn
and shifting a following user turn into the model's role slot
(corrupting role alternation).
**Fix:** add both keys to the non-text detection so those entries are
preserved verbatim.
Closes: no issue filed — both found while auditing the Gemini
contents<->messages round-trip.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/batch.py`: use `_rebuild_gemini_contents`
instead of the raw-index restore loop.
- `headroom/proxy/handlers/gemini.py`: recognize `executableCode` /
`codeExecutionResult` in `_has_non_text_parts`.
- `tests/test_proxy_handlers_batch.py`: add
`test_handle_google_batch_create_preserves_functioncall_response_order`,
driving the handler with the **real** Gemini converters (the existing
batch tests stub them, which hid the bug); mix `GeminiHandlerMixin` into
the shared `DummyBatchHandler` so `_rebuild_gemini_contents` is
available.
- `tests/test_google_multimodal.py`: extend the parametrized
`test_each_non_text_key_detected` to the two new keys, and add
`test_code_execution_entry_survives`.
## Testing
- [x] New regression tests added (`tests/test_proxy_handlers_batch.py`,
`tests/test_google_multimodal.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py headroom/proxy/handlers/gemini.py \
tests/test_proxy_handlers_batch.py tests/test_google_multimodal.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the
interleaving/detection with dependency-free scripts (replicating the
Gemini converters, the old loop, and `_rebuild_gemini_contents`) and
left the full pytest to CI.
- Exact command / steps: ran two standalone scripts. Script 1 rebuilds a
Gemini batch request with `preserved_indices` holding a
`functionCall`/`functionResponse` pair and compares the old raw-index
loop against `_rebuild_gemini_contents`. Script 2 feeds a
`codeExecutionResult` entry through `_has_non_text_parts` and the
preserve path with and without the two new allowlist keys. Also ran `uvx
ruff@0.15.17 check` on the changed files and tests.
- Observed result: the old batch loop drops the `functionResponse` and
overwrites the answer (4 parts collapse to 2);
`_rebuild_gemini_contents` keeps all 4. Without the new keys the
code-execution entry is dropped/shifted (2 parts, code absent); with
them it survives intact (3 parts, code present). Lint clean. See the two
blocks below.
Batch fix (bug #1):
```text
preserved_indices: [1, 2]
OLD result parts: ['text', 'functionCall'] len 2
NEW result parts: ['text', 'functionCall', 'functionResponse', 'text'] len 4
GEMINI BATCH REBUILD FIX VERIFIED (old drops response + overwrites answer; new keeps all 4)
```
Code-execution fix (bug #2):
```text
(b) OLD len=2 NEW len=3
(a) OLD has code=False NEW has code=True
GEMINI CODE-EXECUTION PRESERVE FIX VERIFIED (old drops/shifts; new keeps intact)
```
- Not tested: a live Google/Gemini round-trip (handlers stubbed, as the
existing tests do). Full local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + standalone logic checks; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Two small behavioral changes (one loop -> shared helper, two keys
added to an allowlist) plus regression tests; no new dependencies. Both
complete/extend the non-text preservation the non-batch handlers already
do (the #836 line).
- @JerrettDavis tagging you since you reviewed the recent Gemini fixes.
Both of these drop content (functionResponse/images on batch;
code-execution on the normal round-trip), so they seemed worth surfacing
together. Thanks.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 19:16:35 +05:30
|
|
|
class DummyBatchHandler(batch_module.BatchHandlerMixin, GeminiHandlerMixin):
|
|
|
|
|
# GeminiHandlerMixin supplies the real _rebuild_gemini_contents (and the
|
|
|
|
|
# other content helpers); the two converter methods below intentionally
|
|
|
|
|
# override the mixin's for the stub-based tests.
|
2026-04-23 08:33:29 -05:00
|
|
|
OPENAI_API_URL = "https://openai.example"
|
|
|
|
|
GEMINI_API_URL = "https://gemini.example"
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.http_client = FakeHttpClient()
|
|
|
|
|
self.metrics = FakeMetrics()
|
|
|
|
|
self.config = SimpleNamespace(
|
|
|
|
|
optimize=False,
|
|
|
|
|
ccr_inject_tool=False,
|
|
|
|
|
ccr_inject_system_instructions=False,
|
|
|
|
|
)
|
|
|
|
|
self.openai_provider = SimpleNamespace(get_context_limit=lambda model: 8192)
|
|
|
|
|
self.openai_pipeline = SimpleNamespace(apply=lambda **kwargs: None)
|
|
|
|
|
self._request_counter = 0
|
|
|
|
|
self._retry_response = FakeResponse()
|
|
|
|
|
|
|
|
|
|
async def _next_request_id(self) -> str:
|
|
|
|
|
self._request_counter += 1
|
|
|
|
|
return f"req-{self._request_counter}"
|
|
|
|
|
|
2026-05-14 15:38:50 -07:00
|
|
|
async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001
|
|
|
|
|
# Mirror of HeadroomProxy._record_request_outcome for the batch
|
|
|
|
|
# mixin tests. Delegates to the free funnel so the wire shape
|
|
|
|
|
# matches production.
|
|
|
|
|
from headroom.proxy.outcome import emit_request_outcome
|
|
|
|
|
|
|
|
|
|
await emit_request_outcome(self, outcome)
|
|
|
|
|
|
2026-05-15 17:54:24 -07:00
|
|
|
def _extract_tags(self, headers: dict) -> dict[str, str]:
|
|
|
|
|
# Mirror of HeadroomProxy._extract_tags. Handlers now call this
|
|
|
|
|
# at entry to capture x-headroom-* slicing tags into the outcome.
|
|
|
|
|
return {
|
|
|
|
|
k.lower().replace("x-headroom-", ""): v
|
|
|
|
|
for k, v in headers.items()
|
|
|
|
|
if k.lower().startswith("x-headroom-")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 08:33:29 -05:00
|
|
|
async def handle_passthrough(self, request, base_url): # noqa: ANN001, ANN201
|
|
|
|
|
return {"request": request, "base_url": base_url}
|
|
|
|
|
|
fix(proxy): bound HF tokenizer load and offload token counting off event loop (#1738)
## Description
Fixes #1701.
On Windows, `headroom proxy --anthropic-api-url
https://api.deepseek.com/anthropic` froze: the first `/v1/messages`
request took ~610s (`optimization_latency_ms=609972`) with only
router/lifecycle markers, and afterwards the whole server was a zombie —
`/livez`, `/readyz` and `/health` hung until the process was killed.
`HEADROOM_DETECT_BACKEND=python` was already set, so this was not the
#575/#845 native-detect deadlock.
Root cause: DeepSeek model names route to the HuggingFace tokenizer
backend (`MODEL_PATTERNS` in `headroom/tokenizers/registry.py`).
`HuggingFaceTokenizer` loads lazily, so the registry's construction-time
fallback never fires; the first `count_messages` calls
`AutoTokenizer.from_pretrained(..., trust_remote_code=True)` — unbounded
network downloads/retries — and this ran **synchronously inside the
async Anthropic messages handler** (`get_tokenizer(model)` +
`tokenizer.count_messages(messages)`), outside the 30s
`_run_compression_in_executor` bound. huggingface_hub retry chains on a
restricted network easily reach ~10 minutes, blocking the entire asyncio
event loop; subsequent on-loop counting kept it pinned. tiktoken got a
bounded eager load for the same bug class long ago (#956); the HF
backend never did.
## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
## Changes Made
- `headroom/tokenizers/huggingface.py`: `_load_tokenizer` now tries the
local HF cache first (`local_files_only=True`, no network), then bounds
the network load with `HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS` (default
10s; `0` disables network loads) on a daemon thread. Timeouts/failures
return `None` (cached by `lru_cache`, so the hub is probed at most once
per process per tokenizer) and `count_messages` fails open to char-based
estimation via the existing `_use_fallback()` path.
- `headroom/proxy/handlers/anthropic.py`: new
`AnthropicHandlerMixin._count_tokens_offloaded(model, messages)` runs
`get_tokenizer` + `count_messages` on the compression executor bounded
by `COMPRESSION_TIMEOUT_SECONDS`, failing open to
`EstimatingTokenCounter` (downgrade logged once per model). Used in
`handle_anthropic_messages` (the issue's hot path, both count sites) and
`handle_anthropic_batch_create`; the batch path's inline
`anthropic_pipeline.apply()` is now offloaded via
`_run_compression_in_executor` (mirrors the #1612 image-compression
offload).
- `headroom/proxy/handlers/batch.py`: the two remaining inline
`openai_pipeline.apply()` calls (`handle_google_batch_create`,
`_compress_batch_jsonl`) are offloaded the same way; existing `except`
blocks keep the pass-through fail-open semantics.
- Tests: `tests/test_huggingface_tokenizer_timeout.py` (cache-first,
bounded timeout, failure caching, timeout=0, fail-open estimation),
`tests/test_tokenizer_count_offload.py` (wiring guards, runs on
`headroom-compress` worker, event loop stays responsive during slow
tokenizer work, fail-open), plus `_run_compression_in_executor` stub on
the batch test double.
## Testing
- [x] All existing tests pass
- [x] Added new tests for the changes
- [ ] Manual testing performed
```
$ python -m pytest tests/test_huggingface_tokenizer_timeout.py tests/test_tokenizer_count_offload.py tests/test_image_compression_offload.py tests/test_gemini_compression_offload.py tests/test_tokenizers tests/test_proxy_handlers_batch.py -q
50 passed
$ ruff check . # No issues found
$ ruff format --check . # 1043 files already formatted
$ mypy headroom --ignore-missing-imports # 0 errors
```
## Real Behavior Proof
- Environment: Windows 11 Pro (10.0.26200), Python 3.13, local checkout
of this branch with the Rust core built.
- Exact command / steps: `python -m pytest
tests/test_tokenizer_count_offload.py -q` — includes
`test_count_tokens_offloaded_keeps_loop_responsive`, which reproduces
the issue's mechanism: a tokenizer whose `count_messages` blocks
(stand-in for the unbounded `AutoTokenizer.from_pretrained` network
load) while an asyncio ticker measures event-loop liveness. Also `python
-m pytest tests/test_huggingface_tokenizer_timeout.py -q` with a
`from_pretrained` stub that sleeps 60s and
`HEADROOM_HF_TOKENIZER_LOAD_TIMEOUT_SECS=0.2`.
- Observed result: with the fix, the slow count runs on a
`headroom-compress` worker thread and the loop keeps ticking (`ticks >=
5`; inline it yields ~0 — the zombie). The 60s-hung HF load unblocks at
the 0.2s timeout, falls back to estimation, and the second call returns
instantly (failure cached, no re-probe). All 10 new tests pass.
- Not tested: live reproduction against `api.deepseek.com` from a
network where HF hub downloads stall (the reporter's exact environment);
actual HF vocab download timing on a healthy network.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:46:26 +02:00
|
|
|
async def _run_compression_in_executor(self, fn, *, timeout): # noqa: ANN001, ANN201
|
|
|
|
|
# Mirror of HeadroomProxy._run_compression_in_executor: batch handlers
|
|
|
|
|
# offload pipeline.apply() off the event loop (#1701). Inline is fine
|
|
|
|
|
# for tests — only the call contract matters here.
|
|
|
|
|
return fn()
|
|
|
|
|
|
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
|
|
|
async def _retry_request(self, method, url, headers, body, **kwargs): # noqa: ANN001, ANN201
|
2026-04-23 08:33:29 -05:00
|
|
|
return self._retry_response
|
|
|
|
|
|
|
|
|
|
def _gemini_contents_to_messages(self, contents, system_instruction): # noqa: ANN001, ANN201
|
|
|
|
|
messages = [{"role": "user", "content": part["parts"][0]["text"]} for part in contents]
|
|
|
|
|
return messages, []
|
|
|
|
|
|
|
|
|
|
def _messages_to_gemini_contents(self, messages): # noqa: ANN001, ANN201
|
|
|
|
|
return ([{"parts": [{"text": message["content"]}]} for message in messages], None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeRequest:
|
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
body: bytes | str,
|
|
|
|
|
*,
|
|
|
|
|
headers: dict[str, str] | None = None,
|
|
|
|
|
method: str = "POST",
|
|
|
|
|
path: str = "/v1/batches",
|
|
|
|
|
query: str = "",
|
|
|
|
|
) -> None:
|
|
|
|
|
self._body = body.encode("utf-8") if isinstance(body, str) else body
|
|
|
|
|
self.headers = headers or {}
|
|
|
|
|
self.method = method
|
|
|
|
|
self.url = SimpleNamespace(path=path, query=query)
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
self.query_params = {}
|
feat(proxy): let extensions report cost savings and their own latency (#3051)
## What
Two changes that let a proxy extension report **what it saved** and
**what it cost**, so both show up under `/stats`, the dashboard, and
Prometheus.
`record_scope_savings` already existed and already accepted `usd` — the
one channel in the proxy that can express savings *without* tokens. Two
things stopped it working end to end.
### 1. Savings were silently dropped on Gemini traffic (bug)
`bind_scope` shares one attribution ledger between ASGI middleware and
the request handler. Anthropic and OpenAI call it; **Gemini never did**,
so anything an extension recorded into the request scope was discarded
for Gemini traffic only — silently, because an empty ledger and an
unbound one are indistinguishable at the outcome funnel. Now bound at
all four Gemini tag sites.
### 2. An extension's own latency was invisible (gap)
`overhead_ms` is measured *inside* the handler, and an ASGI extension
**wraps** that handler — so every millisecond it spends reaches the
client while every timing surface stays flat. An extension that halves
the bill and adds 200 ms per request is a trade the operator has to see
both halves of, and only one half was reaching the dashboard.
`record_scope_timing(scope, stage, ms)` is the symmetric counterpart to
`record_scope_savings`, carried on the same bound ledger and merged into
`RequestOutcome.pipeline_timing` at the outcome funnel — one place, so
every provider picks it up at once.
## API surface
```python
from headroom.proxy.savings_attribution import record_scope_savings, record_scope_timing
record_scope_savings(scope, "my_extension", tokens=0, usd=0.004) # money without tokens
record_scope_timing(scope, "my_extension", elapsed_ms)
```
Both take the ASGI `scope`, because middleware has no other way in.
Documented in `extensions.py` — the module extension authors actually
read, and the stability contract for this interface.
- Savings → `/stats` `savings.by_source`, dashboard card,
`headroom_savings_attributed_usd_total{source=...}`
- Timing → `/stats` `pipeline_timing`, dashboard Performance panel,
`headroom_transform_timing_ms_*`
**Attribution only.** These rows explain the headline total; they are
never added to it.
## Changes to existing behavior
- `public_tags` now strips `_headroom_stage_timing` as well as
`_headroom_savings_attribution`. Both ride on `tags` because that is the
one dict reaching the outcome funnel from every handler, and a list and
a dict must not land in a string-keyed label store.
- `pipeline_timing` passed to `metrics.record_request` is merged rather
than passed through **only when an extension contributed timings**; with
no extension the handler's own dict is passed through unchanged
(asserted by identity in the tests).
- Stage names are extension-supplied, so they are capped at 16 and
namespaced `ext:` — `deep_copy` reported by a plugin must never
accumulate into the same series as `deep_copy` measured by the pipeline.
A handler's own timing wins a collision (unreachable while the prefix
stands; the safe way round if it ever goes).
## Failure modes
Both calls are bounded (32 sources, 16 stages), never raise, and never
change a response — telemetry from a plugin must not be able to break
the request it is describing. Non-positive and non-numeric durations are
ignored: a zero is a clock artifact, not an observation, and averaging
it in would drag the mean down exactly where the stage is cheapest to
skip. `timings_from_tags` tolerates junk on the tag.
## Test-double fix
Three Gemini test fakes (`FakeRequest`, `_FakeRequest`,
`_VertexGeminiImageRequest`) had no `.scope`, which every real Starlette
`Request` has. They now do. This is a double that had drifted from the
type it stands in for; the alternative was weakening the handler to
tolerate a request shape that cannot occur in production.
---
## Real behavior proof
**Setup:** macOS 15.4 (darwin 25.4.0), Python 3.12.13, this branch at
`c814b950`, real `create_app` proxy with `respx`-mocked Anthropic
upstream, a demo ASGI extension added via `app.add_middleware`.
**The extension** — written as a third party would, reporting `tokens=0`
because it re-routed `claude-opus-5` → `claude-haiku-4-5`: same tokens,
cheaper model. That is precisely the case no existing Headroom savings
channel can express, since all of them compute `saved = before - after`.
```python
class DemoRouter:
def __init__(self, app): self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
return await self.app(scope, receive, send)
started = time.perf_counter()
record_scope_savings(scope, "routemegood", tokens=0, usd=0.173)
record_scope_timing(scope, "routemegood", (time.perf_counter() - started) * 1000)
await self.app(scope, receive, send)
```
**Ran:** three POSTs to `/v1/messages`, then `GET /stats` and `GET
/metrics`.
**Observed:**
```
upstream call -> 200
upstream call -> 200
upstream call -> 200
=== /stats savings.by_source (what the dashboard renders) ===
[
{
"source": "routemegood",
"realized": true,
"events": 3,
"tokens": 0,
"usd": 0.519
}
]
=== /stats pipeline_timing (dashboard Performance panel) ===
{
"ext:routemegood": {
"average_ms": 0.01,
"max_ms": 0.02,
"count": 3
}
}
=== /metrics ===
# HELP headroom_savings_attributed_tokens_total Tokens attributed to a savings source
# TYPE headroom_savings_attributed_tokens_total counter
headroom_savings_attributed_tokens_total{realized="true",source="routemegood"} 0
# HELP headroom_savings_attributed_usd_total Cost savings attributed to a source; may be negative
# TYPE headroom_savings_attributed_usd_total gauge
headroom_savings_attributed_usd_total{realized="true",source="routemegood"} 0.519
headroom_transform_timing_ms_sum{transform="ext:routemegood"} 0.03
```
`$0.519 = 3 × $0.173` — three requests, correctly accumulated, with
`tokens: 0` throughout.
**Also have (not a substitute for the above):** 22 new unit tests in
`tests/test_extension_attribution.py`, including four that drive the
real `_record_request_outcome` funnel via the same descriptor-binding
harness `test_request_outcome.py` uses.
Full suite on this branch: **10,989 passed, 578 skipped**. Three
failures —
`test_graceful_shutdown.py::test_run_server_installs_cancelled_error_filter`
(full-suite ordering; passes in isolation),
`test_learn/test_integration.py::TestCodexIntegration::test_full_pipeline`,
and `test_release_workflows.py::test_no_native_tls_in_wheel_build_tree`
(needs `cargo`) — **reproduce identically on clean `main`** (`2f4d001c`,
10,967 passed, same 3 failed). Verified by stashing this branch and
re-running the full suite on main in the same tree.
**What I did not test:** a live provider (upstream is `respx`-mocked);
the Gemini `bind_scope` fix against real Google traffic (covered by the
existing 114 Gemini tests, which all pass); the dashboard rendered in a
browser — I verified the JSON shape its templates bind to
(`stats.savings?.by_source`, `stats.pipeline_timing`) rather than the
pixels.
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Tejas Chopra <tejas@Tejass-MacBook-Pro.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:25:47 -07:00
|
|
|
# Every real Starlette Request has one, and handlers now share a
|
|
|
|
|
# per-request attribution ledger through it (savings_attribution).
|
|
|
|
|
self.scope: dict = {"type": "http", "method": method}
|
2026-04-23 08:33:29 -05:00
|
|
|
|
|
|
|
|
async def body(self) -> bytes:
|
|
|
|
|
return self._body
|
|
|
|
|
|
|
|
|
|
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
class NativeGeminiHandler(DummyBatchHandler):
|
|
|
|
|
def __init__(self, responses: list[FakeResponse]) -> None:
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.config.optimize = True
|
|
|
|
|
self.config.ccr_inject_tool = True
|
|
|
|
|
self.config.ccr_inject_system_instructions = False
|
|
|
|
|
self.memory_handler = None
|
|
|
|
|
self.rate_limiter = None
|
|
|
|
|
self.usage_reporter = None
|
|
|
|
|
self.responses = iter(responses)
|
|
|
|
|
self.sent_bodies: list[dict] = []
|
|
|
|
|
from headroom.ccr.response_handler import CCRResponseHandler
|
|
|
|
|
|
|
|
|
|
self.ccr_response_handler = CCRResponseHandler()
|
|
|
|
|
self.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kwargs: SimpleNamespace(
|
|
|
|
|
messages=[
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": "compressed [100 items compressed to 1. Retrieve more: hash=aaaaaaaaaaaaaaaaaaaaaaaa]",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
timing={},
|
|
|
|
|
tokens_before=10,
|
|
|
|
|
tokens_after=5,
|
|
|
|
|
transforms_applied=[],
|
|
|
|
|
waste_signals=SimpleNamespace(to_dict=lambda: {}),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _gemini_contents_to_messages(
|
|
|
|
|
self, contents, system_instruction=None, *, include_function_responses=False
|
|
|
|
|
): # noqa: ANN001, ANN201
|
|
|
|
|
return GeminiHandlerMixin._gemini_contents_to_messages(
|
|
|
|
|
self,
|
|
|
|
|
contents,
|
|
|
|
|
system_instruction,
|
|
|
|
|
include_function_responses=include_function_responses,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _messages_to_gemini_contents(self, messages): # noqa: ANN001, ANN201
|
|
|
|
|
return GeminiHandlerMixin._messages_to_gemini_contents(self, messages)
|
|
|
|
|
|
|
|
|
|
async def _retry_request(self, method, url, headers, body, **kwargs): # noqa: ANN001, ANN201
|
|
|
|
|
self.sent_bodies.append(body)
|
|
|
|
|
return next(self.responses)
|
|
|
|
|
|
|
|
|
|
async def _run_compression_in_executor(self, fn, *, timeout): # noqa: ANN001, ANN201
|
|
|
|
|
return fn()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_native_gemini_compression(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
class Decision:
|
|
|
|
|
should_compress = True
|
|
|
|
|
passthrough_reason = ""
|
|
|
|
|
|
|
|
|
|
def apply_to_tags(self, tags) -> None: # noqa: ANN001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(gemini_module.CompressionDecision, "decide", lambda **kwargs: Decision())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def native_gemini_request(tools=None) -> dict: # noqa: ANN001
|
|
|
|
|
return {
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "compressed input"}]}],
|
|
|
|
|
"generationConfig": {"temperature": 0.2},
|
|
|
|
|
**({"tools": tools} if tools is not None else {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def native_ccr_response() -> FakeResponse:
|
|
|
|
|
return FakeResponse(
|
|
|
|
|
json_data={
|
|
|
|
|
"candidates": [
|
|
|
|
|
{
|
|
|
|
|
"content": {
|
|
|
|
|
"role": "model",
|
|
|
|
|
"parts": [
|
|
|
|
|
{
|
|
|
|
|
"functionCall": {
|
|
|
|
|
"name": "headroom_retrieve",
|
|
|
|
|
"id": "call-1",
|
|
|
|
|
"args": {"hash": "aaaaaaaaaaaaaaaaaaaaaaaa"},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"usageMetadata": {"promptTokenCount": 5},
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_continuation(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
from headroom.ccr.response_handler import CCRToolResult
|
|
|
|
|
|
|
|
|
|
final = FakeResponse(
|
|
|
|
|
json_data={
|
|
|
|
|
"candidates": [{"content": {"role": "model", "parts": [{"text": "final answer"}]}}]
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
handler = NativeGeminiHandler([native_ccr_response(), final])
|
|
|
|
|
handler.ccr_response_handler._execute_retrieval = lambda call: CCRToolResult(
|
|
|
|
|
call.tool_call_id,
|
|
|
|
|
json.dumps({"hash": call.hash_key, "original_content": [{"type": "code"}]}),
|
|
|
|
|
True,
|
|
|
|
|
1,
|
|
|
|
|
"headroom_retrieve",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json", "x-goog-api-key": "secret"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert (
|
|
|
|
|
json.loads(response.body)["candidates"][0]["content"]["parts"][0]["text"] == "final answer"
|
|
|
|
|
), response.body
|
|
|
|
|
assert len(handler.sent_bodies) == 2
|
|
|
|
|
continuation = handler.sent_bodies[1]["contents"]
|
|
|
|
|
assert continuation[-2]["role"] == "model"
|
|
|
|
|
assert continuation[-2]["parts"][0]["functionCall"]["name"] == "headroom_retrieve"
|
|
|
|
|
assert continuation[-1]["role"] == "user"
|
|
|
|
|
assert continuation[-1]["parts"][0]["functionResponse"]["name"] == "headroom_retrieve"
|
|
|
|
|
assert continuation[-1]["parts"][0]["functionResponse"]["id"] == "call-1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
fix(ccr): verify a scanned marker's hash before advertising it (#2908)
## Description
`CCRToolInjector.scan_for_markers()` decides whether a compression
marker is Headroom's own by *shape* alone — any bracket marker carrying
a 24-hex hash counts, per the generic fallback pattern
(`\[.*?compressed.*?hash=([a-f0-9]{24})\]`). Other context tools emit
exactly that shape. Once a foreign hash is scanned,
`has_compressed_content` flips true and the retrieve tool + "Available
hashes" system instruction get injected for a hash this proxy never
stored — the model calls `headroom_retrieve`, gets a guaranteed miss,
and re-does work it already had. Two wasted turns per adopted foreign
hash.
Closes #2836
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/ccr/tool_injection.py`: added
`CCRToolInjector.verify_ownership()` — filters `detected_hashes` down to
hashes the compression store actually recognizes, via the same
`store.exists()` check the retrieve endpoint itself performs. Added a
small `_HashOwnershipStore` Protocol (structural typing, not a hard
dependency on the concrete `CompressionStore` class) and a
`compression_store` constructor field for dependency injection/testing.
`scan_for_markers()` itself is untouched — kept store-independent (pure
regex) rather than baking the check into the scan loop, since that
approach broke 24 existing tests that correctly test "does this shape
match" in isolation.
- `headroom/proxy/handlers/anthropic.py`,
`headroom/proxy/handlers/openai.py`: call `injector.verify_ownership()`
right after `scan_for_markers()` — the two real per-request call sites.
- `verify_ownership()` is also called inside `process_request()` (the
convenience wrapper `batch.py`'s Google path uses), so that path is
covered without a separate call site edit.
- `tests/test_ccr_tool_injection.py`: new `TestVerifyOwnership` class (6
tests) — the exact issue repro, a real-hash-survives case, mixed
own/foreign hashes, explicit store override, store-exception safety
(must not raise), and no-op-on-empty-hashes.
- `tests/test_proxy_anthropic_cache_stability.py`: 3 pre-existing tests
needed updating for the new (correct) behavior — two `_FakeInjector`
test doubles needed a `verify_ownership()` stub added, and one real
end-to-end test needed a genuine store entry seeded (via
`explicit_hash`) for the hash its hand-typed marker references, instead
of asserting on an unverified shape-only match.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — not run locally, will
confirm via CI
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)
### Test Output
```text
$ .venv/Scripts/python -m pytest tests/test_ccr_marker_policy.py tests/test_ccr_tool_always_on.py tests/test_ccr_tool_injection.py tests/test_proxy/test_ccr_frozen_prefix_coupling.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_handlers_batch.py tests/test_proxy_handler_helpers.py -q
151 passed in 15.87s
$ .venv/Scripts/python -m pytest tests/test_proxy_ccr.py tests/test_proxy_openai_responses_stream_ccr.py tests/test_anthropic_ccr_workspace_unbound.py tests/test_compression_store.py tests/test_no_ccr_lossy.py tests/test_proxy_handlers_batch.py -q
121 passed in 20.73s
$ .venv/Scripts/ruff check . && .venv/Scripts/ruff format --check . # touched files only
All checks passed / already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.14.5, local venv
- Exact command / steps: ran the issue's exact 3-line repro
(`CCRToolInjector.scan_for_markers()` on the foreign marker text, then
`verify_ownership()`) before and after the fix; separately verified a
genuinely-Headroom-stored hash (via `store.store(...,
explicit_hash=...)`) still survives verification and still drives
injection
- Observed result: before the fix (scan only, no verify step exists yet)
`has_compressed_content` is `True` for the foreign marker — matches the
bug report exactly. After adding `verify_ownership()`: foreign marker →
`detected_hashes == []`, `has_compressed_content is False`; real stored
hash → `detected_hashes == [real_hash]`, `has_compressed_content is
True`.
- Not tested: have not driven this through a live two-context-tool proxy
session (e.g. Headroom alongside another CCR-shaped tool in the same
conversation) — verified at the unit/integration level (the exact repro
plus the real proxy handler call sites via
`test_proxy_anthropic_cache_stability.py`'s end-to-end `TestClient`
tests), not via a live multi-tool session.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
internal CCR safety behavior, no user-facing docs reference the
marker-adoption mechanism)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable (release-please
generates this automatically from commit messages)
## Additional Notes
Design note on why `verify_ownership()` is a separate step rather than
baked into `scan_for_markers()`: my first attempt did exactly that and
broke 24 tests across `test_ccr_tool_injection.py`,
`test_ccr_marker_policy.py`, `test_proxy_anthropic_cache_stability.py`,
and `test_proxy_handler_helpers.py` — all of them legitimately testing
"does the regex detect this marker shape" independent of any store
state. Keeping the scan pure and adding an explicit, separately-testable
verification step kept that test surface intact while still closing the
real gap at the three places that actually decide whether to advertise
the retrieve tool.
2026-08-13 22:16:21 +05:30
|
|
|
# verify_ownership() (issue #2836) requires the marker's hash to be a
|
|
|
|
|
# real store entry; NativeGeminiHandler's mocked pipeline hand-types
|
|
|
|
|
# "hash=aaaa...aaaa" rather than compressing through the real store.
|
|
|
|
|
reset_compression_store()
|
|
|
|
|
get_compression_store().store(
|
|
|
|
|
original="original content",
|
|
|
|
|
compressed="compressed [100 items compressed to 1]",
|
|
|
|
|
explicit_hash="aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
|
|
|
)
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
handler = NativeGeminiHandler(
|
|
|
|
|
[FakeResponse(json_data={"candidates": [{"content": {"parts": [{"text": "answer"}]}}]})]
|
|
|
|
|
)
|
|
|
|
|
tools = [
|
|
|
|
|
{"functionDeclarations": [{"name": "client_tool"}]},
|
|
|
|
|
{"functionDeclarations": [{"name": "second_tool"}]},
|
|
|
|
|
{"googleSearch": {}},
|
|
|
|
|
{"codeExecution": {}},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request(tools)),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
forwarded_tools = handler.sent_bodies[0]["tools"]
|
|
|
|
|
assert forwarded_tools[2:] == tools[2:]
|
|
|
|
|
declarations = forwarded_tools[0]["functionDeclarations"]
|
|
|
|
|
assert {item["name"] for item in declarations} == {"client_tool", "headroom_retrieve"}
|
|
|
|
|
assert forwarded_tools[1]["functionDeclarations"] == [{"name": "second_tool"}]
|
fix(ccr): verify a scanned marker's hash before advertising it (#2908)
## Description
`CCRToolInjector.scan_for_markers()` decides whether a compression
marker is Headroom's own by *shape* alone — any bracket marker carrying
a 24-hex hash counts, per the generic fallback pattern
(`\[.*?compressed.*?hash=([a-f0-9]{24})\]`). Other context tools emit
exactly that shape. Once a foreign hash is scanned,
`has_compressed_content` flips true and the retrieve tool + "Available
hashes" system instruction get injected for a hash this proxy never
stored — the model calls `headroom_retrieve`, gets a guaranteed miss,
and re-does work it already had. Two wasted turns per adopted foreign
hash.
Closes #2836
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/ccr/tool_injection.py`: added
`CCRToolInjector.verify_ownership()` — filters `detected_hashes` down to
hashes the compression store actually recognizes, via the same
`store.exists()` check the retrieve endpoint itself performs. Added a
small `_HashOwnershipStore` Protocol (structural typing, not a hard
dependency on the concrete `CompressionStore` class) and a
`compression_store` constructor field for dependency injection/testing.
`scan_for_markers()` itself is untouched — kept store-independent (pure
regex) rather than baking the check into the scan loop, since that
approach broke 24 existing tests that correctly test "does this shape
match" in isolation.
- `headroom/proxy/handlers/anthropic.py`,
`headroom/proxy/handlers/openai.py`: call `injector.verify_ownership()`
right after `scan_for_markers()` — the two real per-request call sites.
- `verify_ownership()` is also called inside `process_request()` (the
convenience wrapper `batch.py`'s Google path uses), so that path is
covered without a separate call site edit.
- `tests/test_ccr_tool_injection.py`: new `TestVerifyOwnership` class (6
tests) — the exact issue repro, a real-hash-survives case, mixed
own/foreign hashes, explicit store override, store-exception safety
(must not raise), and no-op-on-empty-hashes.
- `tests/test_proxy_anthropic_cache_stability.py`: 3 pre-existing tests
needed updating for the new (correct) behavior — two `_FakeInjector`
test doubles needed a `verify_ownership()` stub added, and one real
end-to-end test needed a genuine store entry seeded (via
`explicit_hash`) for the hash its hand-typed marker references, instead
of asserting on an unverified shape-only match.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`) — not run locally, will
confirm via CI
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)
### Test Output
```text
$ .venv/Scripts/python -m pytest tests/test_ccr_marker_policy.py tests/test_ccr_tool_always_on.py tests/test_ccr_tool_injection.py tests/test_proxy/test_ccr_frozen_prefix_coupling.py tests/test_proxy_anthropic_cache_stability.py tests/test_proxy_handlers_batch.py tests/test_proxy_handler_helpers.py -q
151 passed in 15.87s
$ .venv/Scripts/python -m pytest tests/test_proxy_ccr.py tests/test_proxy_openai_responses_stream_ccr.py tests/test_anthropic_ccr_workspace_unbound.py tests/test_compression_store.py tests/test_no_ccr_lossy.py tests/test_proxy_handlers_batch.py -q
121 passed in 20.73s
$ .venv/Scripts/ruff check . && .venv/Scripts/ruff format --check . # touched files only
All checks passed / already formatted
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.14.5, local venv
- Exact command / steps: ran the issue's exact 3-line repro
(`CCRToolInjector.scan_for_markers()` on the foreign marker text, then
`verify_ownership()`) before and after the fix; separately verified a
genuinely-Headroom-stored hash (via `store.store(...,
explicit_hash=...)`) still survives verification and still drives
injection
- Observed result: before the fix (scan only, no verify step exists yet)
`has_compressed_content` is `True` for the foreign marker — matches the
bug report exactly. After adding `verify_ownership()`: foreign marker →
`detected_hashes == []`, `has_compressed_content is False`; real stored
hash → `detected_hashes == [real_hash]`, `has_compressed_content is
True`.
- Not tested: have not driven this through a live two-context-tool proxy
session (e.g. Headroom alongside another CCR-shaped tool in the same
conversation) — verified at the unit/integration level (the exact repro
plus the real proxy handler call sites via
`test_proxy_anthropic_cache_stability.py`'s end-to-end `TestClient`
tests), not via a live multi-tool session.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation (N/A —
internal CCR safety behavior, no user-facing docs reference the
marker-adoption mechanism)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable (release-please
generates this automatically from commit messages)
## Additional Notes
Design note on why `verify_ownership()` is a separate step rather than
baked into `scan_for_markers()`: my first attempt did exactly that and
broke 24 tests across `test_ccr_tool_injection.py`,
`test_ccr_marker_policy.py`, `test_proxy_anthropic_cache_stability.py`,
and `test_proxy_handler_helpers.py` — all of them legitimately testing
"does the regex detect this marker shape" independent of any store
state. Keeping the scan pure and adding an explicit, separately-testable
verification step kept that test surface intact while still closing the
real gap at the three places that actually decide whether to advertise
the retrieve tool.
2026-08-13 22:16:21 +05:30
|
|
|
reset_compression_store()
|
fix(gemini): resolve native CCR retrieval calls (#2253)
## Description
Buffered native Gemini requests currently return `headroom_retrieve`
function calls to the client because `GeminiHandlerMixin` never invokes
the shared CCR response handler. This wires native Gemini request and
response translation into the provider handler while reusing the
existing Google CCR extraction, retrieval, round-limit, mixed-tool, and
`functionResponse` machinery.
Streaming native Gemini and Gemini's OpenAI-compatible
`MALFORMED_FUNCTION_CALL` behavior remain separate surfaces.
This follows the current support boundary documented in
https://github.com/headroomlabs-ai/headroom/pull/2044.
Closes #2041
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [x] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Invoke `CCRResponseHandler` for successful buffered native Gemini
responses containing `headroom_retrieve`.
- Build Gemini-native continuation requests with the model
`functionCall` and matching user `functionResponse`.
- Inject the existing Google CCR function declaration while preserving
sibling Gemini tool configurations.
- Preserve mixed client-tool responses, streaming requests, non-CCR
responses, and upstream error bodies.
- Preserve Google `functionCall.id` as `functionResponse.id` through the
shared CCR identity contract.
- Leave streaming requests outside buffered CCR injection.
- Fail closed when an exclusive CCR call remains unresolved after
continuation.
- Update the CCR documentation to describe buffered native Gemini
support and the mixed-tool boundary.
## Testing
- [x] Unit tests pass (`uv run pytest tests/test_proxy_handlers_batch.py
-k "gemini_native_ccr or gemini_stream" -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/gemini.py tests/test_proxy_handlers_batch.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed
### Test Output
```text
Focused tests: `14 passed, 22 deselected` for `uv run pytest tests/test_proxy_handlers_batch.py -k "gemini_native_ccr or gemini_stream" -q`; `43 passed` for `uv run pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py -q`. Scoped Ruff check and format check passed for the changed Python files. Full repository format remains blocked by pre-existing formatting outside this target.
```
## Real Behavior Proof
- Environment: Windows, Python 3.12, commit `7b3a92a8`; local
native-shape behavioral harness with no Gemini credentials.
- Exact command / steps: run the focused native Gemini handler tests,
then capture a live `generateContent` request and continuation after a
Gemini credential is available.
- Observed result: local tests prove the buffered `functionCall` to
`functionResponse` continuation, mixed-tool preservation, declaration
preservation, error forwarding, and retrieval-result shapes.
- Not tested: owner-reaching live Gemini continuation and native Gemini
streaming CCR continuation.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
## Additional Notes
The patch keeps Gemini wire translation in `GeminiHandlerMixin` and
extends the provider-neutral CCR identity fields for Google call ids.
Native streaming continuation and the OpenAI-compatible Gemini round-two
failure are outside this PR.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 00:40:49 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_does_not_duplicate_existing_declaration(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
tools = [
|
|
|
|
|
{"functionDeclarations": [{"name": "client_tool"}]},
|
|
|
|
|
{"functionDeclarations": [{"name": "headroom_retrieve"}]},
|
|
|
|
|
]
|
|
|
|
|
handler = NativeGeminiHandler(
|
|
|
|
|
[FakeResponse(json_data={"candidates": [{"content": {"parts": [{"text": "answer"}]}}]})]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request(tools)),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
names = [
|
|
|
|
|
declaration["name"]
|
|
|
|
|
for tool in handler.sent_bodies[0]["tools"]
|
|
|
|
|
for declaration in tool.get("functionDeclarations", [])
|
|
|
|
|
]
|
|
|
|
|
assert names.count("headroom_retrieve") == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_does_not_inject_into_streaming_request(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
handler = NativeGeminiHandler([FakeResponse()])
|
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
async def fake_stream(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
|
|
|
|
|
captured["body"] = args[2]
|
|
|
|
|
return FakeResponse()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_stream_response", fake_stream, raising=False)
|
|
|
|
|
tools = [{"functionDeclarations": [{"name": "client_tool"}]}]
|
|
|
|
|
await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request(tools)),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:streamGenerateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
streamed_tools = captured["body"]["tools"] # type: ignore[index]
|
|
|
|
|
names = [
|
|
|
|
|
declaration["name"]
|
|
|
|
|
for tool in streamed_tools
|
|
|
|
|
for declaration in tool.get("functionDeclarations", [])
|
|
|
|
|
]
|
|
|
|
|
assert names == ["client_tool"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_mixed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
response_json = {
|
|
|
|
|
"candidates": [
|
|
|
|
|
{
|
|
|
|
|
"content": {
|
|
|
|
|
"parts": [
|
|
|
|
|
{
|
|
|
|
|
"functionCall": {
|
|
|
|
|
"name": "headroom_retrieve",
|
|
|
|
|
"args": {"hash": "aaaaaaaaaaaaaaaaaaaaaaaa"},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{"functionCall": {"name": "client_tool", "args": {}}},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
handler = NativeGeminiHandler([FakeResponse(json_data=response_json)])
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert len(handler.sent_bodies) == 1
|
|
|
|
|
assert json.loads(response.body) == response_json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_non_ccr_function_call_is_not_intercepted(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
response_json = {
|
|
|
|
|
"candidates": [
|
|
|
|
|
{"content": {"parts": [{"functionCall": {"name": "client_tool", "args": {}}}]}}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
handler = NativeGeminiHandler([FakeResponse(json_data=response_json)])
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert len(handler.sent_bodies) == 1
|
|
|
|
|
assert response.body == b"{}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_continuation_error_preserves_upstream_response(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
handler = NativeGeminiHandler(
|
|
|
|
|
[
|
|
|
|
|
native_ccr_response(),
|
|
|
|
|
FakeResponse(status_code=503, content=b"busy", headers={"retry-after": "2"}),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.body == b"busy"
|
|
|
|
|
assert response.headers["retry-after"] == "2"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_continuation_non_json_preserves_upstream_response(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
handler = NativeGeminiHandler(
|
|
|
|
|
[native_ccr_response(), FakeResponse(status_code=200, content=b"upstream")]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.body == b"upstream"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"original_content",
|
|
|
|
|
[[{"type": "code", "text": "print('x')"}], "plain text", {"key": "value"}, 42],
|
|
|
|
|
ids=["code-aware-array", "kompress-text", "mcp-object", "mcp-scalar"],
|
|
|
|
|
)
|
|
|
|
|
async def test_gemini_native_ccr_uses_real_retrieval_result_shape(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, original_content
|
|
|
|
|
) -> None: # noqa: ANN001
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
entry = CompressionEntry(
|
|
|
|
|
hash="a" * 24,
|
|
|
|
|
original_content=json.dumps(original_content),
|
|
|
|
|
compressed_content="compressed",
|
|
|
|
|
original_tokens=10,
|
|
|
|
|
compressed_tokens=2,
|
|
|
|
|
original_item_count=1,
|
|
|
|
|
compressed_item_count=1,
|
|
|
|
|
tool_name="headroom_retrieve",
|
|
|
|
|
tool_call_id="headroom_retrieve",
|
|
|
|
|
query_context=None,
|
|
|
|
|
created_at=0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class Store:
|
|
|
|
|
def get_entry_status(self, hash_key, clean_expired=True): # noqa: ANN001, ARG002
|
|
|
|
|
return {"status": "available", "default_ttl_seconds": 1800}
|
|
|
|
|
|
|
|
|
|
def retrieve(self, hash_key): # noqa: ANN001, ARG002
|
|
|
|
|
return entry
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(response_handler_module, "get_compression_store", lambda: Store())
|
|
|
|
|
handler = NativeGeminiHandler(
|
|
|
|
|
[
|
|
|
|
|
native_ccr_response(),
|
|
|
|
|
FakeResponse(json_data={"candidates": [{"content": {"parts": [{"text": "done"}]}}]}),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
function_response = handler.sent_bodies[1]["contents"][-1]["parts"][0]["functionResponse"]
|
|
|
|
|
assert function_response["response"]["original_content"] == json.dumps(original_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_preserves_non_ccr_response(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
handler = NativeGeminiHandler([FakeResponse(status_code=503, content=b"busy")])
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 503
|
|
|
|
|
assert response.body == b"busy"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gemini_native_ccr_residual(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
install_native_gemini_compression(monkeypatch)
|
|
|
|
|
from headroom.ccr.response_handler import CCRToolResult
|
|
|
|
|
|
|
|
|
|
handler = NativeGeminiHandler([native_ccr_response()] * 4)
|
|
|
|
|
handler.ccr_response_handler._execute_retrieval = lambda call: CCRToolResult(
|
|
|
|
|
"headroom_retrieve", "still unresolved", True, 0
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_gemini_generate_content(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
json.dumps(native_gemini_request()),
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
path="/v1beta/models/gemini-2.5-flash:generateContent",
|
|
|
|
|
),
|
|
|
|
|
"gemini-2.5-flash",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 502
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 08:33:29 -05:00
|
|
|
def install_batch_support_modules(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
*,
|
|
|
|
|
injector_result=None, # noqa: ANN001
|
|
|
|
|
tokenizer_count: int = 10,
|
|
|
|
|
) -> None:
|
|
|
|
|
class FakeInjector:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.kwargs = kwargs
|
|
|
|
|
|
|
|
|
|
def process_request(self, messages, tools): # noqa: ANN001, ANN201
|
|
|
|
|
if injector_result is not None:
|
|
|
|
|
return injector_result
|
|
|
|
|
return messages, tools, False
|
|
|
|
|
|
|
|
|
|
class FakeTokenizer:
|
|
|
|
|
def count_messages(self, messages) -> int: # noqa: ANN001
|
|
|
|
|
return tokenizer_count
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "headroom.ccr", SimpleNamespace(CCRToolInjector=FakeInjector))
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.tokenizers",
|
|
|
|
|
SimpleNamespace(get_tokenizer=lambda model: FakeTokenizer()),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.utils",
|
|
|
|
|
SimpleNamespace(extract_user_query=lambda messages: "query"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_compress_batch_jsonl_without_optimization_handles_invalid_lines(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(monkeypatch, tokenizer_count=12)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
content = "\n".join(
|
|
|
|
|
[
|
|
|
|
|
json.dumps(
|
|
|
|
|
{"body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}}
|
|
|
|
|
),
|
|
|
|
|
json.dumps({"body": {"model": "gpt-4o", "messages": []}}),
|
|
|
|
|
"not-json",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines, stats = await handler._compress_batch_jsonl(content, "req-1")
|
|
|
|
|
|
|
|
|
|
assert len(lines) == 3
|
|
|
|
|
assert json.loads(lines[0])["body"]["messages"][0]["content"] == "hi"
|
|
|
|
|
assert lines[2] == "not-json"
|
|
|
|
|
assert stats == {
|
|
|
|
|
"total_requests": 3,
|
|
|
|
|
"total_original_tokens": 12,
|
|
|
|
|
"total_compressed_tokens": 12,
|
|
|
|
|
"total_tokens_saved": 0,
|
|
|
|
|
"savings_percent": 0.0,
|
|
|
|
|
"errors": 1,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
fix(proxy/batch): don't crash an OpenAI batch on a valid-JSON non-object line (#2316)
## Description
A single malformed line can abort compression for an entire OpenAI batch
upload.
`_compress_batch_jsonl` parses each JSONL line and immediately reads the
request body:
```python
request_obj = json.loads(line)
body = request_obj.get("body", {})
messages = body.get("messages", [])
...
except json.JSONDecodeError as e:
...
compressed_lines.append(line) # keep original on error
```
`json.loads` returns a valid JSON *value*, which isn't necessarily an
object. A line like `[1, 2, 3]`, `"hello"`, or `null` parses fine, but
`request_obj.get(...)` on a list/str/None raises `AttributeError`.
Likewise a request object whose `body` is present but not a dict
(`{"body": "..."}`) makes `body.get("messages", ...)` raise. The
surrounding `except json.JSONDecodeError` doesn't catch
`AttributeError`, so the exception propagates out of
`_compress_batch_jsonl` and the whole batch-create request fails.
This is the OpenAI batch upload path; the file is user-supplied, so a
single stray non-object line takes the batch down instead of just being
passed through like the other non-compressible cases already are.
## Fix
Guard for non-object shapes and pass them through unchanged:
```python
request_obj = json.loads(line)
if not isinstance(request_obj, dict):
compressed_lines.append(line)
total_requests += 1
continue
body = request_obj.get("body", {})
if not isinstance(body, dict):
body = {}
```
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/batch.py`: in `_compress_batch_jsonl`, pass
through a non-dict parsed line and coalesce a non-dict `body` to `{}`.
- `tests/test_proxy_handlers_batch.py`: new test that array / string /
null lines and a non-dict `body` pass through without crashing and are
preserved.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py tests/test_proxy_handlers_batch.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/batch.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the per-line handling with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran lines `[1,2,3]`, `"hello"`, `null`, and
`{"body": "not-a-dict"}` through the OLD (bare `.get`) and NEW
(isinstance-guarded) logic, plus a normal request and a `not-json` line
as controls.
- Observed result: OLD raises `AttributeError` on each non-object line
and on the non-dict body; NEW passes the non-object lines through
unchanged, coalesces the non-dict body to `{}`, still processes a normal
request, and still passes `not-json` through as a JSON-decode error.
- Not tested: a live OpenAI batch upload end-to-end; full local `pytest`
deferred to CI (OOM).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
existing `DummyBatchHandler` / `install_batch_support_modules` harness
in `tests/test_proxy_handlers_batch.py` (the same one the neighbouring
invalid-line test uses), so it runs under the normal CI pytest job;
behaviour is additionally verified by the standalone proof above.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-12 10:17:09 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_compress_batch_jsonl_handles_non_object_lines(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
# A JSONL line that is valid JSON but not a request object (array/string/
|
|
|
|
|
# null), or a request whose `body` isn't a dict, must pass through instead
|
|
|
|
|
# of crashing the whole batch (`.get` on a non-dict raises AttributeError,
|
|
|
|
|
# which the JSONDecodeError guard does not catch).
|
|
|
|
|
install_batch_support_modules(monkeypatch, tokenizer_count=12)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
content = "\n".join(
|
|
|
|
|
[
|
|
|
|
|
json.dumps(
|
|
|
|
|
{"body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}}
|
|
|
|
|
),
|
|
|
|
|
json.dumps([1, 2, 3]),
|
|
|
|
|
json.dumps("hello"),
|
|
|
|
|
"null",
|
|
|
|
|
json.dumps({"body": "not-a-dict"}),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines, stats = await handler._compress_batch_jsonl(content, "req-1")
|
|
|
|
|
|
|
|
|
|
assert len(lines) == 5
|
|
|
|
|
assert json.loads(lines[1]) == [1, 2, 3]
|
|
|
|
|
assert json.loads(lines[2]) == "hello"
|
|
|
|
|
assert json.loads(lines[3]) is None
|
|
|
|
|
assert json.loads(lines[4]) == {"body": "not-a-dict"}
|
|
|
|
|
assert stats["total_requests"] == 5
|
|
|
|
|
# None of these are JSON decode errors, so the error counter stays at 0.
|
|
|
|
|
assert stats["errors"] == 0
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 08:33:29 -05:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_compress_batch_jsonl_uses_pipeline_and_ccr_injection(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
injector_result=(
|
|
|
|
|
[{"role": "system", "content": "compressed"}],
|
|
|
|
|
[{"name": "retrieval"}],
|
|
|
|
|
True,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.optimize = True
|
|
|
|
|
handler.config.ccr_inject_tool = True
|
|
|
|
|
handler.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kwargs: SimpleNamespace(
|
|
|
|
|
messages=[{"role": "assistant", "content": "short"}],
|
|
|
|
|
tokens_before=100,
|
|
|
|
|
tokens_after=40,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines, stats = await handler._compress_batch_jsonl(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"body": {
|
|
|
|
|
"model": "gpt-4o-mini",
|
|
|
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
|
|
|
"tools": [{"name": "existing"}],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
"req-2",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
body = json.loads(lines[0])["body"]
|
|
|
|
|
assert body["messages"] == [{"role": "system", "content": "compressed"}]
|
|
|
|
|
assert body["tools"] == [{"name": "retrieval"}]
|
|
|
|
|
assert stats["total_tokens_saved"] == 60
|
|
|
|
|
assert stats["savings_percent"] == 60.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_compress_batch_jsonl_falls_back_when_pipeline_raises(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(monkeypatch, tokenizer_count=33)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.optimize = True
|
|
|
|
|
handler.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines, stats = await handler._compress_batch_jsonl(
|
|
|
|
|
json.dumps({"body": {"messages": [{"role": "user", "content": "hello"}]}}),
|
|
|
|
|
"req-3",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert json.loads(lines[0])["body"]["messages"][0]["content"] == "hello"
|
|
|
|
|
assert stats["total_original_tokens"] == 33
|
|
|
|
|
assert stats["total_compressed_tokens"] == 33
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_batch_passthrough_forwards_request_and_strips_response_headers() -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.http_client.post_response = FakeResponse(
|
|
|
|
|
content=b'{"ok":true}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "20", "x-kept": "1"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler._batch_passthrough(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
'{"input_file_id":"file-1"}', headers={"host": "example", "content-length": "10"}
|
|
|
|
|
),
|
|
|
|
|
{"input_file_id": "file-1"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert dict(response.headers)["x-kept"] == "1"
|
|
|
|
|
assert "content-encoding" not in dict(response.headers)
|
|
|
|
|
assert handler.http_client.posts[0]["url"] == "https://openai.example/v1/batches"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_batch_create_validates_json_and_required_fields(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
|
|
|
|
|
async def raise_bad_json(request): # noqa: ANN001
|
|
|
|
|
raise ValueError("bad json")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", raise_bad_json)
|
|
|
|
|
|
|
|
|
|
bad = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert bad.status_code == 400
|
|
|
|
|
assert bad.body.decode().find("invalid_json") > 0
|
|
|
|
|
|
|
|
|
|
async def missing_file_payload(request): # noqa: ANN001
|
|
|
|
|
return {"endpoint": "/v1/chat/completions"}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", missing_file_payload)
|
|
|
|
|
missing_file = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert missing_file.status_code == 400
|
|
|
|
|
assert missing_file.body.decode().find("input_file_id is required") > 0
|
|
|
|
|
|
|
|
|
|
async def missing_endpoint_payload(request): # noqa: ANN001
|
|
|
|
|
return {"input_file_id": "file-1"}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", missing_endpoint_payload)
|
|
|
|
|
missing_endpoint = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert missing_endpoint.status_code == 400
|
|
|
|
|
assert missing_endpoint.body.decode().find("endpoint is required") > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_batch_create_passthrough_and_download_failure(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
passthrough_response = SimpleNamespace(marker="passthrough")
|
|
|
|
|
|
|
|
|
|
async def fake_passthrough(request, body): # noqa: ANN001
|
|
|
|
|
return passthrough_response
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_batch_passthrough", fake_passthrough)
|
|
|
|
|
|
|
|
|
|
async def passthrough_payload(request): # noqa: ANN001
|
|
|
|
|
return {"input_file_id": "file-1", "endpoint": "/v1/responses"}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", passthrough_payload)
|
|
|
|
|
assert await handler.handle_batch_create(FakeRequest("{}")) is passthrough_response
|
|
|
|
|
|
|
|
|
|
async def download_missing_payload(request): # noqa: ANN001
|
|
|
|
|
return {"input_file_id": "file-1", "endpoint": "/v1/chat/completions"}
|
|
|
|
|
|
|
|
|
|
async def missing_download(file_id, headers): # noqa: ANN001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", download_missing_payload)
|
|
|
|
|
monkeypatch.setattr(handler, "_download_openai_file", missing_download)
|
|
|
|
|
missing = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert missing.status_code == 404
|
|
|
|
|
assert missing.body.decode().find("file_not_found") > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_batch_create_handles_empty_upload_failure_and_success(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
|
|
|
|
|
async def request_payload(request): # noqa: ANN001
|
|
|
|
|
return {
|
|
|
|
|
"input_file_id": "file-1",
|
|
|
|
|
"endpoint": "/v1/chat/completions",
|
|
|
|
|
"completion_window": "12h",
|
|
|
|
|
"metadata": {"source": "test"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", request_payload)
|
|
|
|
|
|
|
|
|
|
async def fake_download(file_id, headers): # noqa: ANN001
|
|
|
|
|
return "downloaded"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_download_openai_file", fake_download)
|
|
|
|
|
|
|
|
|
|
async def empty_compress(content, request_id): # noqa: ANN001
|
|
|
|
|
return [], {
|
|
|
|
|
"total_requests": 0,
|
|
|
|
|
"total_original_tokens": 0,
|
|
|
|
|
"total_compressed_tokens": 0,
|
|
|
|
|
"total_tokens_saved": 0,
|
|
|
|
|
"savings_percent": 0.0,
|
|
|
|
|
"errors": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_compress_batch_jsonl", empty_compress)
|
|
|
|
|
empty = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert empty.status_code == 400
|
|
|
|
|
assert empty.body.decode().find("empty_file") > 0
|
|
|
|
|
|
|
|
|
|
async def compressed(content, request_id): # noqa: ANN001
|
|
|
|
|
return ['{"body":{}}'], {
|
|
|
|
|
"total_requests": 1,
|
|
|
|
|
"total_original_tokens": 20,
|
|
|
|
|
"total_compressed_tokens": 10,
|
|
|
|
|
"total_tokens_saved": 10,
|
|
|
|
|
"savings_percent": 50.0,
|
|
|
|
|
"errors": 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_compress_batch_jsonl", compressed)
|
|
|
|
|
|
|
|
|
|
async def upload_failed_file(content, filename, headers): # noqa: ANN001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_upload_openai_file", upload_failed_file)
|
|
|
|
|
upload_failed = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
assert upload_failed.status_code == 500
|
|
|
|
|
assert upload_failed.body.decode().find("upload_failed") > 0
|
|
|
|
|
|
|
|
|
|
handler.http_client.post_response = FakeResponse(
|
|
|
|
|
content=b'{"id":"batch_123","object":"batch"}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "12", "x-openai": "1"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def upload_success(content, filename, headers): # noqa: ANN001
|
|
|
|
|
return "file-compressed"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_upload_openai_file", upload_success)
|
|
|
|
|
success = await handler.handle_batch_create(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
"{}", headers={"host": "proxy", "content-length": "4", "authorization": "Bearer test"}
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert success.status_code == 200
|
|
|
|
|
success_headers = dict(success.headers)
|
|
|
|
|
assert success_headers["x-headroom-tokens-saved"] == "10"
|
|
|
|
|
assert success_headers["x-headroom-savings-percent"] == "50.0"
|
|
|
|
|
assert success_headers["x-openai"] == "1"
|
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
|
|
|
# PR-A3: byte-faithful forwarder writes ``content`` (raw bytes), not
|
|
|
|
|
# ``json``. Round-trip the captured bytes back to a dict for assertion.
|
|
|
|
|
last_post = handler.http_client.posts[-1]
|
|
|
|
|
if "json" in last_post:
|
|
|
|
|
sent_body = last_post["json"]
|
|
|
|
|
else:
|
|
|
|
|
sent_body = json.loads(last_post["content"].decode("utf-8"))
|
2026-04-23 08:33:29 -05:00
|
|
|
assert sent_body["metadata"]["headroom_compressed"] == "true"
|
|
|
|
|
assert sent_body["metadata"]["headroom_original_file_id"] == "file-1"
|
|
|
|
|
assert handler.metrics.record_calls[-1]["provider"] == "openai"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_batch_create_records_failure_on_exception(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
|
|
|
|
|
async def request_payload(request): # noqa: ANN001
|
|
|
|
|
return {"input_file_id": "file-1", "endpoint": "/v1/chat/completions"}
|
|
|
|
|
|
|
|
|
|
async def boom(file_id, headers): # noqa: ANN001
|
|
|
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", request_payload)
|
|
|
|
|
monkeypatch.setattr(handler, "_download_openai_file", boom)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_batch_create(FakeRequest("{}"))
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 500
|
|
|
|
|
assert handler.metrics.failed_calls == [{"provider": "batch"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_download_and_upload_openai_file_helpers() -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.http_client.get_response = FakeResponse(status_code=200, text="jsonl-content")
|
|
|
|
|
downloaded = await handler._download_openai_file("file-1", {"authorization": "Bearer token"})
|
|
|
|
|
assert downloaded == "jsonl-content"
|
|
|
|
|
assert handler.http_client.gets[0]["url"] == "https://openai.example/v1/files/file-1/content"
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(status_code=404, text="missing")
|
|
|
|
|
assert await handler._download_openai_file("file-2", {}) is None
|
|
|
|
|
|
|
|
|
|
handler.http_client.post_response = FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
json_data={"id": "file-uploaded"},
|
|
|
|
|
headers={"content-type": "application/json"},
|
|
|
|
|
)
|
|
|
|
|
file_id = await handler._upload_openai_file(
|
|
|
|
|
'{"body":{}}',
|
|
|
|
|
"compressed.jsonl",
|
|
|
|
|
{"authorization": "Bearer token", "content-type": "application/json"},
|
|
|
|
|
)
|
|
|
|
|
assert file_id == "file-uploaded"
|
|
|
|
|
post_call = handler.http_client.posts[-1]
|
|
|
|
|
assert post_call["headers"] == {"authorization": "Bearer token"}
|
|
|
|
|
assert post_call["files"]["file"][0] == "compressed.jsonl"
|
|
|
|
|
|
|
|
|
|
handler.http_client.post_response = FakeResponse(status_code=500, text="fail")
|
|
|
|
|
assert await handler._upload_openai_file("{}", "bad.jsonl", {}) is None
|
|
|
|
|
handler.http_client.raise_post = RuntimeError("network")
|
|
|
|
|
assert await handler._upload_openai_file("{}", "bad.jsonl", {}) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_store_google_batch_context_persists_transformed_requests(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
stored_contexts: list[object] = []
|
|
|
|
|
|
|
|
|
|
class FakeBatchContext:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.kwargs = kwargs
|
|
|
|
|
self.requests: list[object] = []
|
|
|
|
|
|
|
|
|
|
def add_request(self, request) -> None: # noqa: ANN001
|
|
|
|
|
self.requests.append(request)
|
|
|
|
|
|
|
|
|
|
class FakeBatchRequestContext:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.kwargs = kwargs
|
|
|
|
|
|
|
|
|
|
class FakeStore:
|
|
|
|
|
async def store(self, context) -> None: # noqa: ANN001
|
|
|
|
|
stored_contexts.append(context)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.ccr",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
BatchContext=FakeBatchContext,
|
|
|
|
|
BatchRequestContext=FakeBatchRequestContext,
|
|
|
|
|
get_batch_context_store=lambda: FakeStore(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
await handler._store_google_batch_context(
|
|
|
|
|
"batches/123",
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"metadata": {"key": "req-1"},
|
|
|
|
|
"request": {
|
|
|
|
|
"contents": [{"parts": [{"text": "hello"}]}],
|
|
|
|
|
"systemInstruction": {"parts": [{"text": "system"}]},
|
|
|
|
|
"tools": [{"name": "tool"}],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"gemini-2.0",
|
|
|
|
|
"api-key",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
context = stored_contexts[0]
|
|
|
|
|
assert context.kwargs["batch_id"] == "batches/123"
|
|
|
|
|
assert context.requests[0].kwargs["custom_id"] == "req-1"
|
|
|
|
|
assert context.requests[0].kwargs["messages"] == [{"role": "user", "content": "hello"}]
|
|
|
|
|
assert context.requests[0].kwargs["system_instruction"] == "system"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_results_passes_through_early_exit_cases(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
class FakeStore:
|
|
|
|
|
async def get(self, batch_name): # noqa: ANN001
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.ccr",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
BatchResultProcessor=lambda http_client: None,
|
|
|
|
|
get_batch_context_store=lambda: FakeStore(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
request = FakeRequest(
|
|
|
|
|
"{}", headers={"x-goog-api-key": "secret"}, method="GET", path="/v1beta/batches/b1"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
status_code=500, content=b"bad", headers={"x-upstream": "1"}
|
|
|
|
|
)
|
|
|
|
|
error_response = await handler.handle_google_batch_results(request, "batches/b1")
|
|
|
|
|
assert error_response.status_code == 500
|
|
|
|
|
assert dict(error_response.headers)["x-upstream"] == "1"
|
|
|
|
|
|
|
|
|
|
class BadJsonResponse(FakeResponse):
|
|
|
|
|
def json(self): # noqa: ANN201
|
|
|
|
|
raise json.JSONDecodeError("bad", "x", 0)
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = BadJsonResponse(
|
|
|
|
|
status_code=200, content=b"plain", headers={"x-upstream": "2"}
|
|
|
|
|
)
|
|
|
|
|
non_json = await handler.handle_google_batch_results(request, "batches/b1")
|
|
|
|
|
assert non_json.status_code == 200
|
|
|
|
|
assert dict(non_json.headers)["x-upstream"] == "2"
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
content=b"{}",
|
|
|
|
|
json_data={"metadata": {"state": "RUNNING"}},
|
|
|
|
|
)
|
|
|
|
|
running = await handler.handle_google_batch_results(request, "batches/b1")
|
|
|
|
|
assert running.status_code == 200
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
content=b"{}",
|
|
|
|
|
json_data={"metadata": {"state": "SUCCEEDED"}, "response": {"responses": []}},
|
|
|
|
|
)
|
|
|
|
|
no_results = await handler.handle_google_batch_results(request, "batches/b1")
|
|
|
|
|
assert no_results.status_code == 200
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
content=b"{}",
|
|
|
|
|
json_data={"metadata": {"state": "SUCCEEDED"}, "response": {"responses": [{"id": 1}]}},
|
|
|
|
|
)
|
|
|
|
|
handler.config.ccr_inject_tool = False
|
|
|
|
|
no_ccr = await handler.handle_google_batch_results(request, "batches/b1")
|
|
|
|
|
assert no_ccr.status_code == 200
|
|
|
|
|
assert "key=secret" in handler.http_client.gets[-1]["url"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_results_processes_completed_results(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
processed_calls: list[tuple[str, list[object], str]] = []
|
|
|
|
|
|
|
|
|
|
class FakeProcessed:
|
|
|
|
|
def __init__(
|
|
|
|
|
self, result, custom_id: str, was_processed: bool, continuation_rounds: int
|
|
|
|
|
) -> None: # noqa: ANN001
|
|
|
|
|
self.result = result
|
|
|
|
|
self.custom_id = custom_id
|
|
|
|
|
self.was_processed = was_processed
|
|
|
|
|
self.continuation_rounds = continuation_rounds
|
|
|
|
|
|
|
|
|
|
class FakeProcessor:
|
|
|
|
|
def __init__(self, http_client) -> None: # noqa: ANN001
|
|
|
|
|
self.http_client = http_client
|
|
|
|
|
|
|
|
|
|
async def process_results(self, batch_name, results, provider): # noqa: ANN001
|
|
|
|
|
processed_calls.append((batch_name, results, provider))
|
|
|
|
|
return [
|
|
|
|
|
FakeProcessed({"id": "processed"}, "req-1", True, 2),
|
|
|
|
|
FakeProcessed({"id": "unchanged"}, "req-2", False, 0),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
class FakeStore:
|
|
|
|
|
async def get(self, batch_name): # noqa: ANN001
|
|
|
|
|
return SimpleNamespace(batch_name=batch_name)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.ccr",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
BatchResultProcessor=FakeProcessor,
|
|
|
|
|
get_batch_context_store=lambda: FakeStore(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.ccr_inject_tool = True
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
content=b"{}",
|
|
|
|
|
json_data={
|
|
|
|
|
"metadata": {"state": "SUCCEEDED"},
|
|
|
|
|
"response": {"responses": [{"id": "raw-1"}, {"id": "raw-2"}]},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_google_batch_results(
|
|
|
|
|
FakeRequest("{}", method="GET", path="/v1beta/batches/b1"),
|
|
|
|
|
"batches/b1",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
payload = json.loads(response.body)
|
|
|
|
|
assert payload["response"]["responses"] == [{"id": "processed"}, {"id": "unchanged"}]
|
|
|
|
|
assert processed_calls == [("batches/b1", [{"id": "raw-1"}, {"id": "raw-2"}], "google")]
|
|
|
|
|
assert handler.metrics.record_calls[-1]["model"] == "batch:ccr-processed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_google_batch_passthrough_helpers_forward_and_track_metrics() -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.http_client.post_response = FakeResponse(
|
|
|
|
|
content=b'{"ok":true}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "10", "x-kept": "1"},
|
|
|
|
|
)
|
|
|
|
|
handler.http_client.post_response = FakeResponse(
|
|
|
|
|
content=b'{"ok":true}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "10", "x-kept": "1"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
passthrough = await handler._google_batch_passthrough(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
"body", headers={"host": "proxy", "content-length": "4", "x-goog-api-key": "secret"}
|
|
|
|
|
),
|
|
|
|
|
"gemini-pro",
|
|
|
|
|
{"batch": {}},
|
|
|
|
|
)
|
|
|
|
|
assert passthrough.status_code == 200
|
|
|
|
|
assert dict(passthrough.headers)["x-kept"] == "1"
|
|
|
|
|
assert "key=secret" in handler.http_client.posts[-1]["url"]
|
|
|
|
|
assert handler.metrics.record_calls[-1]["model"] == "passthrough:batch:gemini-pro"
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(
|
|
|
|
|
content=b'{"state":"ok"}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "10", "x-kept": "2"},
|
|
|
|
|
)
|
|
|
|
|
response = await handler.handle_google_batch_passthrough(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
"ping",
|
|
|
|
|
headers={"host": "proxy", "x-goog-api-key": "secret"},
|
|
|
|
|
method="DELETE",
|
|
|
|
|
path="/v1beta/batches/b1",
|
|
|
|
|
query="alt=json",
|
|
|
|
|
),
|
|
|
|
|
"b1",
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert dict(response.headers)["x-kept"] == "2"
|
|
|
|
|
get_call = handler.http_client.requests[-1]
|
|
|
|
|
assert get_call["url"] == "https://gemini.example/v1beta/batches/b1?alt=json&key=secret"
|
|
|
|
|
assert handler.metrics.record_calls[-1]["model"] == "passthrough:batches"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_create_validates_and_passthroughs(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(monkeypatch)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
|
|
|
|
|
too_large = await handler.handle_google_batch_create(
|
|
|
|
|
FakeRequest("{}", headers={"content-length": str(200 * 1024 * 1024)}),
|
|
|
|
|
"gemini-pro",
|
|
|
|
|
)
|
|
|
|
|
assert too_large.status_code == 413
|
|
|
|
|
|
|
|
|
|
async def bad_json(request): # noqa: ANN001
|
|
|
|
|
raise ValueError("bad json")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", bad_json)
|
|
|
|
|
invalid = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
assert invalid.status_code == 400
|
|
|
|
|
|
|
|
|
|
passthrough_response = SimpleNamespace(kind="passthrough")
|
|
|
|
|
|
|
|
|
|
async def fake_google_passthrough(request, model, body=None): # noqa: ANN001
|
|
|
|
|
return passthrough_response
|
|
|
|
|
|
|
|
|
|
async def no_inline(request): # noqa: ANN001
|
|
|
|
|
return {"batch": {"input_config": {"requests": {"requests": []}}}}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", no_inline)
|
|
|
|
|
monkeypatch.setattr(handler, "_google_batch_passthrough", fake_google_passthrough)
|
|
|
|
|
assert (
|
|
|
|
|
await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
is passthrough_response
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_create_success_and_failure_paths(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(monkeypatch)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.optimize = True
|
|
|
|
|
handler.config.ccr_inject_tool = True
|
|
|
|
|
handler.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kwargs: SimpleNamespace(
|
|
|
|
|
messages=[{"role": "user", "content": "compressed"}],
|
|
|
|
|
timing={"compress": 1.2},
|
|
|
|
|
tokens_before=100,
|
|
|
|
|
tokens_after=40,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class FakeInjector:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def process_request(self, messages, tools): # noqa: ANN001, ANN201
|
|
|
|
|
return (
|
|
|
|
|
messages + [{"role": "system", "content": "retrieval"}],
|
|
|
|
|
[{"name": "retrieval"}],
|
|
|
|
|
True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setitem(sys.modules, "headroom.ccr", SimpleNamespace(CCRToolInjector=FakeInjector))
|
|
|
|
|
|
|
|
|
|
stored: list[tuple[str, list[dict[str, object]], str, str | None]] = []
|
|
|
|
|
|
|
|
|
|
async def fake_store(batch_name, requests_list, model, api_key): # noqa: ANN001
|
|
|
|
|
stored.append((batch_name, requests_list, model, api_key))
|
|
|
|
|
|
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
|
|
|
async def fake_retry(method, url, headers, body, **kwargs): # noqa: ANN001
|
2026-04-23 08:33:29 -05:00
|
|
|
return FakeResponse(
|
|
|
|
|
status_code=200,
|
|
|
|
|
content=b'{"name":"batches/123"}',
|
|
|
|
|
headers={"content-encoding": "gzip", "content-length": "10", "x-upstream": "1"},
|
|
|
|
|
json_data={"name": "batches/123"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def good_payload(request): # noqa: ANN001
|
|
|
|
|
return {
|
|
|
|
|
"batch": {
|
|
|
|
|
"input_config": {
|
|
|
|
|
"requests": {
|
|
|
|
|
"requests": [
|
|
|
|
|
{
|
|
|
|
|
"request": {
|
|
|
|
|
"contents": [{"parts": [{"text": "hello"}]}],
|
|
|
|
|
"tools": [{"functionDeclarations": [{"name": "existing"}]}],
|
|
|
|
|
},
|
|
|
|
|
"metadata": {"key": "req-1"},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", good_payload)
|
|
|
|
|
monkeypatch.setattr(handler, "_retry_request", fake_retry)
|
|
|
|
|
monkeypatch.setattr(handler, "_store_google_batch_context", fake_store)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_google_batch_create(
|
|
|
|
|
FakeRequest("{}", headers={"x-goog-api-key": "secret"}),
|
|
|
|
|
"gemini-pro",
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert dict(response.headers)["x-upstream"] == "1"
|
|
|
|
|
assert handler.metrics.record_calls[-1]["provider"] == "google"
|
|
|
|
|
assert handler.metrics.record_calls[-1]["tokens_saved"] == 60
|
|
|
|
|
assert stored[0][0] == "batches/123"
|
|
|
|
|
assert stored[0][2:] == ("gemini-pro", "secret")
|
|
|
|
|
assert stored[0][1][0]["metadata"] == {"key": "req-1"}
|
|
|
|
|
|
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
|
|
|
async def broken_retry(method, url, headers, body, **kwargs): # noqa: ANN001
|
2026-04-23 08:33:29 -05:00
|
|
|
raise RuntimeError("forward failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(handler, "_retry_request", broken_retry)
|
|
|
|
|
failed = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
assert failed.status_code == 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_create_covers_passthrough_revert_and_store_failures(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(
|
|
|
|
|
monkeypatch, injector_result=([{"role": "user", "content": "kept"}], None, False)
|
|
|
|
|
)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.optimize = True
|
|
|
|
|
handler.config.ccr_inject_tool = True
|
|
|
|
|
|
|
|
|
|
pipeline_calls: list[dict[str, object]] = []
|
|
|
|
|
handler.openai_pipeline = SimpleNamespace(
|
2026-04-23 08:49:50 -05:00
|
|
|
apply=lambda **kwargs: (
|
|
|
|
|
pipeline_calls.append(kwargs)
|
|
|
|
|
or SimpleNamespace(
|
|
|
|
|
messages=[{"role": "user", "content": "inflated"}],
|
|
|
|
|
timing={},
|
|
|
|
|
tokens_before=40,
|
|
|
|
|
tokens_after=80,
|
|
|
|
|
)
|
2026-04-23 08:33:29 -05:00
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fake_to_messages(contents, system_instruction): # noqa: ANN001, ANN201
|
|
|
|
|
if contents and "inlineData" in contents[0]["parts"][0]:
|
|
|
|
|
return ([{"role": "user", "content": "binary"}], [0])
|
|
|
|
|
return ([{"role": "user", "content": "compress"}], [])
|
|
|
|
|
|
|
|
|
|
def fake_to_gemini(messages): # noqa: ANN001, ANN201
|
|
|
|
|
return ([{"parts": [{"text": "new"}]}], {"parts": [{"text": "sys"}]})
|
|
|
|
|
|
|
|
|
|
async def payload(request): # noqa: ANN001
|
|
|
|
|
return {
|
|
|
|
|
"batch": {
|
|
|
|
|
"input_config": {
|
|
|
|
|
"requests": {
|
|
|
|
|
"requests": [
|
|
|
|
|
{"request": {"contents": []}, "metadata": {"key": "empty"}},
|
|
|
|
|
{
|
|
|
|
|
"request": {"contents": [{"parts": [{"inlineData": "x"}]}]},
|
|
|
|
|
"metadata": {"key": "preserved"},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"request": {
|
|
|
|
|
"contents": [{"parts": [{"text": "hello"}]}],
|
|
|
|
|
"tools": [
|
|
|
|
|
{"other": True},
|
|
|
|
|
{"functionDeclarations": [{"name": "existing"}]},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
"metadata": {"key": "optimized"},
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
seen_bodies: list[dict[str, object]] = []
|
|
|
|
|
|
fix: A3 — byte-faithful Python forwarders; serialize canonical only when mutated
Eliminates P0-2 universally. Every Python forwarder (server.py
`_retry_request`, handlers/streaming.py `_stream_response`,
handlers/openai.py `_ws_http_fallback`, handlers/batch.py `_batch_passthrough`
+ batch-create + Google batch passthrough, handlers/anthropic.py CCR
continuation + batch endpoint) now switches from `httpx ... json=body` to
`httpx ... content=raw_bytes`. The default httpx JSON encoder was
re-serializing every request with `, `/`: ` separators and `\\uXXXX` ASCII
escapes — collapsing Anthropic prompt-cache hit-rate.
Forwarder strategy:
- unmutated body → forward `await request.body()` verbatim;
- mutated body → re-serialize once via the new
`serialize_body_canonical(body) -> bytes` helper (compact separators,
`ensure_ascii=False`, dict insertion order preserved).
`HEADROOM_PROXY_PYTHON_FORWARDER_MODE` env var configures the mode:
- `byte_faithful` (default) — the new behavior;
- `legacy_json_kwarg` — explicit operator opt-in for emergency rollback.
Documented in `docs/content/docs/configuration.mdx`. NOT a fallback —
unknown values raise loudly per build constraint #4.
`BodyMutationTracker` accompanies each request through the handler so
transform sites mark the tracker (`memory_injection`,
`image_compression`, `compression_*`, `batch_compression`,
`ccr_continuation`, etc.). At forwarder dispatch we additionally compare
the final body dict against the parsed original bytes as a structural
safety net — any silent mutation we missed still triggers canonical
re-serialization.
A2 follow-up: `handlers/openai.py:534-540` (Chat Completions memory
injection) was prepending a system message; replaced with
`append_text_to_latest_user_chat_message`, the OpenAI Chat Completions
analog of `_append_context_to_latest_non_frozen_user_turn`. The cache
hot zone (system messages) is now sacrosanct on /v1/chat/completions
too. Honors `HEADROOM_MEMORY_INJECTION_MODE=disabled`.
Structured logging: every forwarder emits an `event=outbound_request`
log line with `forwarder`, `path`, `body_bytes`, `body_mutated`,
`mutation_reasons`, `source` (passthrough|canonical|legacy),
`request_id`. Never logs Authorization or full body.
`_read_request_json` factored to share `_read_request_body_bytes` with
new `read_request_json_with_bytes` so the anthropic handler can capture
both the parsed dict and the original (decompressed) bytes.
Tests:
- `tests/test_proxy_byte_faithful_forwarding.py` (28 tests):
SHA-256 byte-equality on /v1/messages and streaming, unicode
preservation, numeric precision, mutation-tracker invariants,
canonical-serializer properties, legacy-mode rollback, OpenAI
Chat memory routing.
- Existing test mocks updated to accept the new `**kwargs` on
`_retry_request` (no behavior change).
- `tests/test_proxy_handlers_batch.py` updated to read the captured
`content=` bytes (formerly `json=`).
- One A2 test corrected (`test_anthropic_tool_sort_and_context_append_helpers`)
to match the live-zone-tail semantics introduced by A2.
Constraints satisfied: configurable env var; no new regex / hardcodes;
no silent fallback (`legacy_json_kwarg` is operator opt-in);
performant (`prepare_outbound_body_bytes` is O(1) for passthrough);
elegant single-responsibility helpers; structured tracing logs.
2026-05-02 09:02:10 -07:00
|
|
|
async def retry(method, url, headers, body, **kwargs): # noqa: ANN001
|
2026-04-23 08:33:29 -05:00
|
|
|
seen_bodies.append(body)
|
|
|
|
|
return FakeResponse(status_code=200, content=b"{}", json_data={"name": "batches/123"})
|
|
|
|
|
|
|
|
|
|
async def broken_store(batch_name, requests_list, model, api_key): # noqa: ANN001
|
|
|
|
|
raise RuntimeError("store failed")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", payload)
|
|
|
|
|
monkeypatch.setattr(handler, "_gemini_contents_to_messages", fake_to_messages)
|
|
|
|
|
monkeypatch.setattr(handler, "_messages_to_gemini_contents", fake_to_gemini)
|
|
|
|
|
monkeypatch.setattr(handler, "_retry_request", retry)
|
|
|
|
|
monkeypatch.setattr(handler, "_store_google_batch_context", broken_store)
|
|
|
|
|
|
|
|
|
|
response = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert len(pipeline_calls) == 1
|
|
|
|
|
assert handler.metrics.record_calls[-1]["tokens_saved"] == 0
|
|
|
|
|
assert (
|
|
|
|
|
seen_bodies[0]["batch"]["input_config"]["requests"]["requests"][0]["metadata"]["key"]
|
|
|
|
|
== "empty"
|
|
|
|
|
)
|
|
|
|
|
optimized = seen_bodies[0]["batch"]["input_config"]["requests"]["requests"][2]["request"]
|
|
|
|
|
assert optimized["contents"][0] == {"parts": [{"text": "new"}]}
|
|
|
|
|
assert optimized["systemInstruction"] == {"parts": [{"text": "sys"}]}
|
|
|
|
|
|
|
|
|
|
|
fix(proxy/gemini): preserve non-text content across the compression round-trip (#2079)
## Description
Two related content-loss bugs in the Gemini `contents[]` <->
`messages[]` compression round-trip.
Both drop or misplace real user content that entries with **non-text**
parts should carry through
untouched. They share the same theme (non-text preservation), so they're
bundled here as two
commits.
### 1. Google batch handler restores preserved entries by the wrong
index (`handlers/batch.py`)
The `batchGenerateContent` handler restored preserved (non-text) entries
with the raw-index loop
that commit #836 (`_rebuild_gemini_contents`) replaced in the three
non-batch Gemini handlers:
```python
for orig_idx, original_content in preserved_contents.items():
if orig_idx < len(optimized_contents):
optimized_contents[orig_idx] = original_content
```
`preserved_indices` are indices into the **original** `contents[]`, but
`optimized_contents` is a
**shorter** list (text-less entries produce no message). Indexing
`optimized_contents` by
`orig_idx` overwrites the wrong entry and drops any preserved entry
whose original index is past
the optimized length. For:
```python
[user text, model functionCall, user functionResponse, model text]
```
the batch was forwarded to Google as **two** entries: the model's answer
overwritten by the
functionCall, and the functionResponse dropped. Unlike `gemini.py` there
is no
`if optimized_messages != messages` gate, so it runs on every mixed
batch item.
**Fix:** use the shared `_rebuild_gemini_contents` interleaving helper.
### 2. Code-execution parts not detected as non-text
(`handlers/gemini.py`)
`_has_non_text_parts` only recognized
`inlineData`/`fileData`/`functionCall`/`functionResponse`.
Gemini's code-execution feature emits `executableCode` and
`codeExecutionResult` parts, echoed
back in `contents[]` on later turns. Because they weren't detected:
- a mixed `text`+`executableCode` entry lost its code payload (only the
text survived the round-trip);
- a text-less `executableCode`+`codeExecutionResult` entry was treated
as a phantom in
`_rebuild_gemini_contents` — it consumed the next optimized message,
dropping the whole code turn
and shifting a following user turn into the model's role slot
(corrupting role alternation).
**Fix:** add both keys to the non-text detection so those entries are
preserved verbatim.
Closes: no issue filed — both found while auditing the Gemini
contents<->messages round-trip.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/proxy/handlers/batch.py`: use `_rebuild_gemini_contents`
instead of the raw-index restore loop.
- `headroom/proxy/handlers/gemini.py`: recognize `executableCode` /
`codeExecutionResult` in `_has_non_text_parts`.
- `tests/test_proxy_handlers_batch.py`: add
`test_handle_google_batch_create_preserves_functioncall_response_order`,
driving the handler with the **real** Gemini converters (the existing
batch tests stub them, which hid the bug); mix `GeminiHandlerMixin` into
the shared `DummyBatchHandler` so `_rebuild_gemini_contents` is
available.
- `tests/test_google_multimodal.py`: extend the parametrized
`test_each_non_text_key_detected` to the two new keys, and add
`test_code_execution_entry_survives`.
## Testing
- [x] New regression tests added (`tests/test_proxy_handlers_batch.py`,
`tests/test_google_multimodal.py`)
- [x] Linting/formatting clean — run with the CI-pinned `ruff==0.15.17`
- [ ] Full `pytest` deferred to CI (local-OOM reason below).
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py headroom/proxy/handlers/gemini.py \
tests/test_proxy_handlers_batch.py tests/test_google_multimodal.py
All checks passed!
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, headroom from this branch.
Importing `headroom` pulls in the torch/transformers stack and a full
`pytest` gets OOM-killed on this box, so I verified the
interleaving/detection with dependency-free scripts (replicating the
Gemini converters, the old loop, and `_rebuild_gemini_contents`) and
left the full pytest to CI.
- Exact command / steps: ran two standalone scripts. Script 1 rebuilds a
Gemini batch request with `preserved_indices` holding a
`functionCall`/`functionResponse` pair and compares the old raw-index
loop against `_rebuild_gemini_contents`. Script 2 feeds a
`codeExecutionResult` entry through `_has_non_text_parts` and the
preserve path with and without the two new allowlist keys. Also ran `uvx
ruff@0.15.17 check` on the changed files and tests.
- Observed result: the old batch loop drops the `functionResponse` and
overwrites the answer (4 parts collapse to 2);
`_rebuild_gemini_contents` keeps all 4. Without the new keys the
code-execution entry is dropped/shifted (2 parts, code absent); with
them it survives intact (3 parts, code present). Lint clean. See the two
blocks below.
Batch fix (bug #1):
```text
preserved_indices: [1, 2]
OLD result parts: ['text', 'functionCall'] len 2
NEW result parts: ['text', 'functionCall', 'functionResponse', 'text'] len 4
GEMINI BATCH REBUILD FIX VERIFIED (old drops response + overwrites answer; new keeps all 4)
```
Code-execution fix (bug #2):
```text
(b) OLD len=2 NEW len=3
(a) OLD has code=False NEW has code=True
GEMINI CODE-EXECUTION PRESERVE FIX VERIFIED (old drops/shifts; new keeps intact)
```
- Not tested: a live Google/Gemini round-trip (handlers stubbed, as the
existing tests do). Full local `pytest` deferred to CI (OOM, per above).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes — ran
lint + standalone logic checks; full pytest deferred to CI (local OOM,
disclosed above)
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Two small behavioral changes (one loop -> shared helper, two keys
added to an allowlist) plus regression tests; no new dependencies. Both
complete/extend the non-text preservation the non-batch handlers already
do (the #836 line).
- @JerrettDavis tagging you since you reviewed the recent Gemini fixes.
Both of these drop content (functionResponse/images on batch;
code-execution on the normal round-trip), so they seemed worth surfacing
together. Thanks.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-13 19:16:35 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_create_preserves_functioncall_response_order(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A batch request that interleaves text turns with text-less
|
|
|
|
|
functionCall/functionResponse entries must reach Google with all entries
|
|
|
|
|
intact and in order. The old raw-index restore loop overwrote the model's
|
|
|
|
|
answer with the functionCall and dropped the functionResponse."""
|
|
|
|
|
|
|
|
|
|
class RealConvHandler(batch_module.BatchHandlerMixin, GeminiHandlerMixin):
|
|
|
|
|
# Real Gemini converters + _rebuild_gemini_contents (no stubs), so the
|
|
|
|
|
# actual index interleaving runs.
|
|
|
|
|
GEMINI_API_URL = "https://gemini.example"
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.http_client = FakeHttpClient()
|
|
|
|
|
self.metrics = FakeMetrics()
|
|
|
|
|
self.config = SimpleNamespace(
|
|
|
|
|
optimize=True, ccr_inject_tool=False, ccr_inject_system_instructions=False
|
|
|
|
|
)
|
|
|
|
|
self.openai_provider = SimpleNamespace(get_context_limit=lambda m: 8192)
|
|
|
|
|
# No-op pipeline: return the messages unchanged, no token inflation.
|
|
|
|
|
self.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kw: SimpleNamespace(
|
|
|
|
|
messages=kw["messages"], timing={}, tokens_before=100, tokens_after=100
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.captured_body: dict | None = None
|
|
|
|
|
|
|
|
|
|
async def _next_request_id(self) -> str:
|
|
|
|
|
return "req-1"
|
|
|
|
|
|
|
|
|
|
async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _extract_tags(self, headers: dict) -> dict[str, str]:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
async def _run_compression_in_executor(self, fn, *, timeout): # noqa: ANN001, ANN201
|
|
|
|
|
return fn()
|
|
|
|
|
|
|
|
|
|
async def _store_google_batch_context(self, *a, **k) -> None: # noqa: ANN002, ANN003
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def _retry_request(self, method, url, headers, body, **kwargs): # noqa: ANN001, ANN201
|
|
|
|
|
# Capture the (in-place mutated) forwarded batch body for assertions.
|
|
|
|
|
self.captured_body = body
|
|
|
|
|
return FakeResponse(status_code=200, content=b"{}", json_data={"name": "batches/1"})
|
|
|
|
|
|
|
|
|
|
handler = RealConvHandler()
|
|
|
|
|
|
|
|
|
|
contents = [
|
|
|
|
|
{"role": "user", "parts": [{"text": "What's the weather in Paris?"}]},
|
|
|
|
|
{
|
|
|
|
|
"role": "model",
|
|
|
|
|
"parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"parts": [{"functionResponse": {"name": "get_weather", "response": {"temp_c": 18}}}],
|
|
|
|
|
},
|
|
|
|
|
{"role": "model", "parts": [{"text": "It's 18C and cloudy in Paris."}]},
|
|
|
|
|
]
|
|
|
|
|
batch_body = {
|
|
|
|
|
"batch": {
|
|
|
|
|
"input_config": {
|
|
|
|
|
"requests": {"requests": [{"request": {"contents": contents}, "metadata": {}}]}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def payload(request): # noqa: ANN001, ANN201
|
|
|
|
|
return batch_body
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", payload)
|
|
|
|
|
|
|
|
|
|
resp = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
|
|
|
|
out = handler.captured_body["batch"]["input_config"]["requests"]["requests"][0]["request"][
|
|
|
|
|
"contents"
|
|
|
|
|
]
|
|
|
|
|
# All four entries survive in order. The old loop produced only two, dropping
|
|
|
|
|
# the functionResponse and overwriting the model answer with the functionCall.
|
|
|
|
|
assert len(out) == 4
|
|
|
|
|
assert "text" in out[0]["parts"][0]
|
|
|
|
|
assert out[1]["parts"][0].get("functionCall", {}).get("name") == "get_weather"
|
|
|
|
|
assert out[2]["parts"][0].get("functionResponse", {}).get("name") == "get_weather"
|
|
|
|
|
assert "Paris" in out[3]["parts"][0]["text"]
|
|
|
|
|
|
|
|
|
|
|
fix(proxy/batch): preserve sibling tool configs on Google batch requests (#2177)
## Description
When Headroom optimizes a Google/Gemini batch request, it silently drops
every tool config that isn't `functionDeclarations`.
In `handle_google_batch_create` the per-item optimizer extracts the
function declarations:
```python
tools = req_content.get("tools")
existing_funcs = None
if tools:
for tool in tools:
if "functionDeclarations" in tool:
existing_funcs = tool["functionDeclarations"]
break
```
and then rebuilds the forwarded request's tools as a single entry:
```python
if existing_funcs is not None:
compressed_req_content["tools"] = [{"functionDeclarations": existing_funcs}]
```
Gemini's `tools` array is a list of heterogeneous entries —
`{"functionDeclarations": [...]}` can sit alongside `{"googleSearch":
{}}` and `{"codeExecution": {}}`. Collapsing the array to one
`functionDeclarations` entry discards those siblings, so a batch request
that combines function calling with Google Search or code execution
reaches Google with those features stripped out. The request still
succeeds, so the loss is silent — the model just never grounds against
Search / never runs code.
The branch fires whenever the item had any `functionDeclarations` (or
CCR injected a retrieval tool), i.e. exactly the requests most likely to
also declare Search/code-execution.
## Fix
Rebuild the tools list from the original, replacing only the
`functionDeclarations` entry with the (possibly CCR-injected) funcs and
appending a new entry when the original had none:
```python
rebuilt_tools = []
replaced = False
for tool in tools or []:
if "functionDeclarations" in tool:
rebuilt_tools.append({**tool, "functionDeclarations": existing_funcs})
replaced = True
else:
rebuilt_tools.append(tool)
if not replaced:
rebuilt_tools.append({"functionDeclarations": existing_funcs})
compressed_req_content["tools"] = rebuilt_tools
```
Sibling entries (`googleSearch`, `codeExecution`, ...) are preserved in
place; the search/no-search behavior of the request is unchanged.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/proxy/handlers/batch.py`: preserve
non-`functionDeclarations` tool entries when rebuilding the optimized
Gemini batch request's tools array.
- `tests/test_proxy_handlers_batch.py`: new regression test asserting
`googleSearch` / `codeExecution` survive alongside
`functionDeclarations` in the forwarded body (uses the existing
`RealConvHandler` harness with the real converters).
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/proxy/handlers/batch.py tests/test_proxy_handlers_batch.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/handlers/batch.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the array rebuild with a dependency-free script and left the
full pytest to CI.
- Exact command / steps: fed a tools array of
`[{functionDeclarations:[get_weather]}, {googleSearch:{}},
{codeExecution:{}}]` (plus a CCR-injected retrieval function) through
the OLD single-entry rebuild and the NEW preserving rebuild; also the
search-only case where CCR injects the first `functionDeclarations`.
- Observed result: OLD → `[{functionDeclarations:[...]}]` only
(googleSearch and codeExecution gone); NEW → all three entries retained
with the injected retrieval function present in `functionDeclarations`;
the search-only case gains a `functionDeclarations` entry while keeping
`googleSearch`.
- Not tested: a live Gemini batch submission; full local `pytest`
deferred to CI (OOM).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test reuses the
in-file `RealConvHandler` harness (same one the existing
`..._preserves_functioncall_response_order` test uses) so it runs under
the normal CI pytest job; behaviour is additionally verified by the
standalone proof above.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
2026-07-14 21:52:21 +05:30
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_handle_google_batch_create_preserves_sibling_tools(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A batch request whose tools array carries googleSearch / codeExecution
|
|
|
|
|
alongside functionDeclarations must reach Google with those siblings intact.
|
|
|
|
|
The old code collapsed the whole array to a single functionDeclarations
|
|
|
|
|
entry, silently disabling Google Search and code execution."""
|
|
|
|
|
|
|
|
|
|
class RealConvHandler(batch_module.BatchHandlerMixin, GeminiHandlerMixin):
|
|
|
|
|
GEMINI_API_URL = "https://gemini.example"
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
self.http_client = FakeHttpClient()
|
|
|
|
|
self.metrics = FakeMetrics()
|
|
|
|
|
self.config = SimpleNamespace(
|
|
|
|
|
optimize=True, ccr_inject_tool=False, ccr_inject_system_instructions=False
|
|
|
|
|
)
|
|
|
|
|
self.openai_provider = SimpleNamespace(get_context_limit=lambda m: 8192)
|
|
|
|
|
self.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kw: SimpleNamespace(
|
|
|
|
|
messages=kw["messages"], timing={}, tokens_before=100, tokens_after=100
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
self.captured_body: dict | None = None
|
|
|
|
|
|
|
|
|
|
async def _next_request_id(self) -> str:
|
|
|
|
|
return "req-1"
|
|
|
|
|
|
|
|
|
|
async def _record_request_outcome(self, outcome) -> None: # noqa: ANN001
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def _extract_tags(self, headers: dict) -> dict[str, str]:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
async def _run_compression_in_executor(self, fn, *, timeout): # noqa: ANN001, ANN201
|
|
|
|
|
return fn()
|
|
|
|
|
|
|
|
|
|
async def _store_google_batch_context(self, *a, **k) -> None: # noqa: ANN002, ANN003
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
async def _retry_request(self, method, url, headers, body, **kwargs): # noqa: ANN001, ANN201
|
|
|
|
|
self.captured_body = body
|
|
|
|
|
return FakeResponse(status_code=200, content=b"{}", json_data={"name": "batches/1"})
|
|
|
|
|
|
|
|
|
|
handler = RealConvHandler()
|
|
|
|
|
|
|
|
|
|
tools = [
|
|
|
|
|
{"functionDeclarations": [{"name": "get_weather"}]},
|
|
|
|
|
{"googleSearch": {}},
|
|
|
|
|
{"codeExecution": {}},
|
|
|
|
|
]
|
|
|
|
|
batch_body = {
|
|
|
|
|
"batch": {
|
|
|
|
|
"input_config": {
|
|
|
|
|
"requests": {
|
|
|
|
|
"requests": [
|
|
|
|
|
{
|
|
|
|
|
"request": {
|
|
|
|
|
"contents": [{"role": "user", "parts": [{"text": "hello there"}]}],
|
|
|
|
|
"tools": tools,
|
|
|
|
|
},
|
|
|
|
|
"metadata": {},
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async def payload(request): # noqa: ANN001, ANN201
|
|
|
|
|
return batch_body
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", payload)
|
|
|
|
|
|
|
|
|
|
resp = await handler.handle_google_batch_create(FakeRequest("{}"), "gemini-pro")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
|
|
|
|
out_tools = handler.captured_body["batch"]["input_config"]["requests"]["requests"][0][
|
|
|
|
|
"request"
|
|
|
|
|
]["tools"]
|
|
|
|
|
keys = [next(iter(entry)) for entry in out_tools]
|
|
|
|
|
assert "googleSearch" in keys
|
|
|
|
|
assert "codeExecution" in keys
|
|
|
|
|
assert "functionDeclarations" in keys
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 08:33:29 -05:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_google_batch_passthrough_without_body_and_query_variants() -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.http_client.post_response = FakeResponse(content=b"ok", headers={"x-upstream": "1"})
|
|
|
|
|
|
|
|
|
|
response = await handler._google_batch_passthrough(
|
|
|
|
|
FakeRequest("raw-body", headers={"host": "proxy"}, method="POST"),
|
|
|
|
|
"gemini-pro",
|
|
|
|
|
)
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert handler.http_client.posts[-1]["content"] == b"raw-body"
|
|
|
|
|
|
|
|
|
|
handler.http_client.get_response = FakeResponse(content=b"{}", headers={"x-upstream": "2"})
|
|
|
|
|
passthrough = await handler.handle_google_batch_passthrough(
|
|
|
|
|
FakeRequest(
|
|
|
|
|
"{}",
|
|
|
|
|
headers={"host": "proxy", "x-goog-api-key": "secret"},
|
|
|
|
|
method="GET",
|
|
|
|
|
path="/v1beta/batches/b1",
|
|
|
|
|
),
|
|
|
|
|
"b1",
|
|
|
|
|
)
|
|
|
|
|
assert passthrough.status_code == 200
|
|
|
|
|
assert (
|
|
|
|
|
handler.http_client.requests[-1]["url"]
|
|
|
|
|
== "https://gemini.example/v1beta/batches/b1?key=secret"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_batch_helper_methods_and_openai_file_error_branches() -> None:
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
marker = object()
|
|
|
|
|
|
|
|
|
|
async def fake_passthrough(request, base_url): # noqa: ANN001
|
|
|
|
|
return marker
|
|
|
|
|
|
|
|
|
|
handler.handle_passthrough = fake_passthrough
|
|
|
|
|
request = FakeRequest("{}")
|
|
|
|
|
assert await handler.handle_batch_list(request) is marker
|
|
|
|
|
assert await handler.handle_batch_get(request, "b1") is marker
|
|
|
|
|
assert await handler.handle_batch_cancel(request, "b1") is marker
|
|
|
|
|
|
|
|
|
|
handler.http_client.raise_get = RuntimeError("download boom")
|
|
|
|
|
assert await handler._download_openai_file("file-1", {}) is None
|
|
|
|
|
|
|
|
|
|
handler.http_client.raise_get = None
|
|
|
|
|
handler.http_client.post_response = FakeResponse(status_code=200, json_data={})
|
|
|
|
|
assert await handler._upload_openai_file("{}", "missing-id.jsonl", {}) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_store_google_batch_context_without_system_text(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
stored_contexts: list[object] = []
|
|
|
|
|
|
|
|
|
|
class FakeBatchContext:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.kwargs = kwargs
|
|
|
|
|
self.requests: list[object] = []
|
|
|
|
|
|
|
|
|
|
def add_request(self, request) -> None: # noqa: ANN001
|
|
|
|
|
self.requests.append(request)
|
|
|
|
|
|
|
|
|
|
class FakeBatchRequestContext:
|
|
|
|
|
def __init__(self, **kwargs) -> None: # noqa: ANN003
|
|
|
|
|
self.kwargs = kwargs
|
|
|
|
|
|
|
|
|
|
class FakeStore:
|
|
|
|
|
async def store(self, context) -> None: # noqa: ANN001
|
|
|
|
|
stored_contexts.append(context)
|
|
|
|
|
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
monkeypatch.setitem(
|
|
|
|
|
sys.modules,
|
|
|
|
|
"headroom.ccr",
|
|
|
|
|
SimpleNamespace(
|
|
|
|
|
BatchContext=FakeBatchContext,
|
|
|
|
|
BatchRequestContext=FakeBatchRequestContext,
|
|
|
|
|
get_batch_context_store=lambda: FakeStore(),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await handler._store_google_batch_context(
|
|
|
|
|
"batches/456",
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"request": {
|
|
|
|
|
"contents": [{"parts": [{"text": "hello"}]}],
|
|
|
|
|
"systemInstruction": {"parts": ["bad"]},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
"gemini-2.0",
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
context = stored_contexts[0]
|
|
|
|
|
assert context.kwargs["api_key"] is None
|
|
|
|
|
assert context.requests[0].kwargs["custom_id"] == ""
|
|
|
|
|
assert context.requests[0].kwargs["system_instruction"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_compress_batch_jsonl_skips_blank_lines_and_preserves_tools_when_not_injected(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
install_batch_support_modules(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
injector_result=([{"role": "assistant", "content": "short"}], [{"name": "orig"}], False),
|
|
|
|
|
)
|
|
|
|
|
handler = DummyBatchHandler()
|
|
|
|
|
handler.config.optimize = True
|
|
|
|
|
handler.config.ccr_inject_tool = True
|
|
|
|
|
handler.openai_pipeline = SimpleNamespace(
|
|
|
|
|
apply=lambda **kwargs: SimpleNamespace(
|
|
|
|
|
messages=[{"role": "assistant", "content": "short"}],
|
|
|
|
|
tokens_before=50,
|
|
|
|
|
tokens_after=10,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lines, stats = await handler._compress_batch_jsonl(
|
|
|
|
|
"\n"
|
|
|
|
|
+ json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"body": {
|
|
|
|
|
"model": "gpt-4o",
|
|
|
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
|
|
|
"tools": [{"name": "orig"}],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
+ "\n",
|
|
|
|
|
"req-extra",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(lines) == 1
|
|
|
|
|
body = json.loads(lines[0])["body"]
|
|
|
|
|
assert body["tools"] == [{"name": "orig"}]
|
|
|
|
|
assert stats["total_requests"] == 1
|
|
|
|
|
assert stats["errors"] == 0
|