mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description Compress Hermes Studio scoped coding-agent passthrough requests in the generic OpenAI passthrough handler. Hermes can route scoped Claude Code and Codex traffic through Headroom while preserving its own proxy paths; this PR keeps Hermes responsible for scoped proxy authentication/provider adaptation while still applying Headroom compression to supported chat payloads before forwarding. The compression remains narrow-scoped: - Only chat messages with `user` or `assistant` roles are compressed. - Tool, function, reasoning, and system items are preserved byte-stable. - Non-dict items in the Responses `input` array are preserved and spliced back. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 - Detect `/api/codex-proxy/.../v1/responses` paths and compress supported Responses `input` chat items before forwarding. - Detect `/api/claude-code-proxy/.../v1/messages` paths and compress supported Anthropic `messages` payloads before forwarding. - Preserve bypass, malformed payload, missing-model, tool/function, reasoning/system, and non-dict passthrough behavior. - Add regression coverage in `tests/test_hermes_passthrough_compression.py`. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_hermes_passthrough_compression.py -v test_codex_proxy_preserves_tool_and_function_items PASSED test_codex_proxy_preserves_nondict_items PASSED test_codex_proxy_bypass_header_skips_compression PASSED test_codex_proxy_malformed_input_preserved PASSED test_codex_proxy_compression_applies_to_chat_messages PASSED test_claude_proxy_preserves_tool_use_items PASSED test_claude_proxy_bypass_header_skips_compression PASSED test_claude_proxy_no_model_forwarded_unchanged PASSED test_claude_proxy_compression_applies_to_chat_messages PASSED test_non_hermes_routes_not_affected PASSED ``` ## Real Behavior Proof - Environment: Author-reported local test environment for `headroom/proxy/handlers/openai.py` and `tests/test_hermes_passthrough_compression.py`. - Exact command / steps: `python -m pytest tests/test_hermes_passthrough_compression.py -v`. - Observed result: The 10 Hermes passthrough regression tests passed, covering Codex and Claude scoped proxy routes plus preservation/bypass cases. - Not tested: End-to-end Hermes Studio traffic against a live upstream service is not covered by this PR body evidence. ## 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 - [ ] 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 - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes Generated with Claude Code. The unchecked checklist items are not required for this narrow proxy-handler test change. --------- Co-authored-by: x1051445024 <你的GitHub注册邮箱> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
484 lines
16 KiB
Python
484 lines
16 KiB
Python
"""Regression tests for Hermes Studio scoped coding-agent passthrough compression.
|
|
|
|
Verifies the new compression logic in ``handle_passthrough`` that rewrites
|
|
``/api/codex-proxy/.../v1/responses`` and ``/api/claude-code-proxy/.../v1/messages``
|
|
request bodies before forwarding upstream.
|
|
|
|
Key invariants tested:
|
|
- Chat messages (role=user/assistant) are compressed
|
|
- Non-chat items (tool, function, reasoning, system) are preserved byte-stable
|
|
- Non-dict items in the input array are preserved
|
|
- x-headroom-bypass header skips compression entirely
|
|
- Malformed/unsupported payloads are forwarded unchanged
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from headroom.providers.hermes import ( # noqa: E402
|
|
compress_scoped_passthrough_body,
|
|
is_scoped_coding_agent_path,
|
|
)
|
|
from headroom.proxy.loopback_guard import require_loopback # noqa: E402
|
|
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
|
|
|
|
|
|
def _make_app(**kwargs: Any):
|
|
"""Create a minimal test app with loopback guard bypassed."""
|
|
config = ProxyConfig(
|
|
optimize=True,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
**kwargs,
|
|
)
|
|
app = create_app(config)
|
|
app.dependency_overrides[require_loopback] = lambda: None
|
|
return app
|
|
|
|
|
|
def _mock_upstream(
|
|
router: respx.MockRouter, upstream_url: str = "https://api.openai.com"
|
|
) -> dict[str, Any]:
|
|
"""Install a mock upstream that captures the forwarded request body."""
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _capture(request: httpx.Request) -> httpx.Response:
|
|
try:
|
|
captured["body"] = json.loads(request.content)
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
captured["body"] = request.content
|
|
captured["url"] = str(request.url)
|
|
captured["method"] = request.method
|
|
captured["headers"] = dict(request.headers)
|
|
return httpx.Response(
|
|
200,
|
|
json={"id": "resp_1", "output": [], "usage": {"input_tokens": 10, "output_tokens": 1}},
|
|
)
|
|
|
|
# Mock any upstream path
|
|
router.route(method="POST", url__startswith=upstream_url).mock(side_effect=_capture)
|
|
router.route(method="GET", url__startswith=upstream_url).mock(
|
|
return_value=httpx.Response(200, json={"data": []})
|
|
)
|
|
return captured
|
|
|
|
|
|
# ── Codex proxy (Responses API) ──────────────────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_codex_proxy_preserves_tool_and_function_items() -> None:
|
|
"""Tool/function/reasoning items are preserved after compression."""
|
|
original_input: list[Any] = [
|
|
{"role": "system", "content": "You are a coding assistant."},
|
|
{"role": "user", "content": "Write a sort function."},
|
|
{
|
|
"type": "function_call",
|
|
"call_id": "call_1",
|
|
"name": "read_file",
|
|
"arguments": '{"path":"/tmp/x.py"}',
|
|
},
|
|
{"type": "function_call_output", "call_id": "call_1", "output": "def foo(): pass"},
|
|
{"role": "assistant", "content": "I see the file contains a foo function."},
|
|
{"role": "user", "content": "Add error handling."},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/api/codex-proxy/some-session/v1/responses",
|
|
headers={"authorization": "Bearer test-key"},
|
|
json={"model": "gpt-4o-mini", "input": original_input},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded_input = captured["body"]["input"]
|
|
|
|
# Must have same length (all items preserved)
|
|
assert len(forwarded_input) == len(original_input), (
|
|
f"Input length changed: {len(original_input)} -> {len(forwarded_input)}"
|
|
)
|
|
|
|
# Non-chat items must be identical
|
|
for idx in [0, 2, 3]: # system, function_call, function_call_output
|
|
assert forwarded_input[idx] == original_input[idx], (
|
|
f"Item {idx} was mutated: {forwarded_input[idx]} != {original_input[idx]}"
|
|
)
|
|
|
|
# Chat items (user/assistant) should still be present (may be compressed)
|
|
for idx in [1, 4, 5]:
|
|
assert isinstance(forwarded_input[idx], dict), f"Item {idx} is no longer a dict"
|
|
assert forwarded_input[idx].get("role") == original_input[idx].get("role"), (
|
|
f"Item {idx} role changed"
|
|
)
|
|
|
|
|
|
@respx.mock
|
|
def test_codex_proxy_preserves_nondict_items() -> None:
|
|
"""Non-dict items in the input array survive compression."""
|
|
original_input: list[Any] = [
|
|
{"role": "user", "content": "hello"},
|
|
"a plain string that is not a dict",
|
|
42,
|
|
{"role": "assistant", "content": "hi there"},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/api/codex-proxy/session-1/v1/responses",
|
|
headers={"authorization": "Bearer test-key"},
|
|
json={"model": "gpt-4o-mini", "input": original_input},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded_input = captured["body"]["input"]
|
|
|
|
assert len(forwarded_input) == len(original_input)
|
|
# Non-dict items preserved exactly
|
|
assert forwarded_input[1] == "a plain string that is not a dict"
|
|
assert forwarded_input[2] == 42
|
|
# Dict items still present
|
|
assert forwarded_input[0].get("role") == "user"
|
|
assert forwarded_input[3].get("role") == "assistant"
|
|
|
|
|
|
@respx.mock
|
|
def test_codex_proxy_bypass_header_skips_compression() -> None:
|
|
"""x-headroom-bypass: true prevents any body mutation."""
|
|
original_input = [
|
|
{"role": "user", "content": "compressible " + "text " * 200},
|
|
{"role": "assistant", "content": "response"},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/api/codex-proxy/session-1/v1/responses",
|
|
headers={
|
|
"authorization": "Bearer test-key",
|
|
"x-headroom-bypass": "true",
|
|
},
|
|
json={"model": "gpt-4o-mini", "input": original_input},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Body must be identical (no compression)
|
|
assert captured["body"]["input"] == original_input
|
|
|
|
|
|
@respx.mock
|
|
def test_codex_proxy_malformed_input_preserved() -> None:
|
|
"""Malformed input (no model) is forwarded without mutation."""
|
|
original_input = [
|
|
{"role": "user", "content": "hello"},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/api/codex-proxy/session-1/v1/responses",
|
|
headers={"authorization": "Bearer test-key"},
|
|
json={"input": original_input}, # no "model" key
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Must be forwarded unchanged
|
|
assert captured["body"]["input"] == original_input
|
|
|
|
|
|
@respx.mock
|
|
def test_codex_proxy_compression_applies_to_chat_messages() -> None:
|
|
"""Chat messages are compressed when model is present and bypass is off."""
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/api/codex-proxy/session-1/v1/responses",
|
|
headers={"authorization": "Bearer test-key"},
|
|
json={
|
|
"model": "gpt-4o-mini",
|
|
"input": [
|
|
{"role": "user", "content": "hello world"},
|
|
{"role": "assistant", "content": "hi"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded_input = captured["body"]["input"]
|
|
assert len(forwarded_input) >= 2 # at least original count
|
|
# Roles preserved
|
|
assert forwarded_input[0].get("role") == "user"
|
|
assert forwarded_input[1].get("role") == "assistant"
|
|
|
|
|
|
# ── Claude Code proxy (Anthropic Messages API) ───────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_claude_proxy_preserves_tool_use_items() -> None:
|
|
"""tool_use and tool_result messages are preserved after compression."""
|
|
original_messages = [
|
|
{"role": "user", "content": [{"type": "text", "text": "Read the file."}]},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_1",
|
|
"name": "read_file",
|
|
"input": {"path": "/tmp/x.py"},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "print('hello')"}
|
|
],
|
|
},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "The file prints hello."}]},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(
|
|
respx, upstream_url="https://httpbin.org"
|
|
) # any URL, overridden by header
|
|
|
|
response = client.post(
|
|
"/api/claude-code-proxy/session-1/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer test-key",
|
|
"x-headroom-base-url": "https://httpbin.org",
|
|
},
|
|
json={"model": "claude-sonnet-4-5-20250929", "messages": original_messages},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded_messages = captured["body"]["messages"]
|
|
|
|
assert len(forwarded_messages) == len(original_messages)
|
|
|
|
# tool_use message preserved
|
|
assert forwarded_messages[1]["role"] == "assistant"
|
|
assert forwarded_messages[1]["content"][0]["type"] == "tool_use"
|
|
|
|
# tool_result message preserved
|
|
assert forwarded_messages[2]["role"] == "user"
|
|
assert forwarded_messages[2]["content"][0]["type"] == "tool_result"
|
|
|
|
|
|
@respx.mock
|
|
def test_claude_proxy_bypass_header_skips_compression() -> None:
|
|
"""x-headroom-bypass: true prevents any body mutation on Claude proxy."""
|
|
original_messages = [
|
|
{"role": "user", "content": "compressible " + "text " * 200},
|
|
{"role": "assistant", "content": "response"},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx, upstream_url="https://httpbin.org")
|
|
|
|
response = client.post(
|
|
"/api/claude-code-proxy/session-1/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer test-key",
|
|
"x-headroom-bypass": "true",
|
|
"x-headroom-base-url": "https://httpbin.org",
|
|
},
|
|
json={"model": "claude-sonnet-4-5-20250929", "messages": original_messages},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert captured["body"]["messages"] == original_messages
|
|
|
|
|
|
@respx.mock
|
|
def test_claude_proxy_no_model_forwarded_unchanged() -> None:
|
|
"""Missing model → forwarded without compression."""
|
|
original_messages = [
|
|
{"role": "user", "content": "hello"},
|
|
]
|
|
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx, upstream_url="https://httpbin.org")
|
|
|
|
response = client.post(
|
|
"/api/claude-code-proxy/session-1/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer test-key",
|
|
"x-headroom-base-url": "https://httpbin.org",
|
|
},
|
|
json={"messages": original_messages},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert captured["body"]["messages"] == original_messages
|
|
|
|
|
|
@respx.mock
|
|
def test_claude_proxy_compression_applies_to_chat_messages() -> None:
|
|
"""Chat messages (user/assistant) are compressed."""
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx, upstream_url="https://httpbin.org")
|
|
|
|
response = client.post(
|
|
"/api/claude-code-proxy/session-1/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer test-key",
|
|
"x-headroom-base-url": "https://httpbin.org",
|
|
},
|
|
json={
|
|
"model": "claude-sonnet-4-5-20250929",
|
|
"messages": [
|
|
{"role": "user", "content": "hello world"},
|
|
{"role": "assistant", "content": "hi"},
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
forwarded_messages = captured["body"]["messages"]
|
|
assert len(forwarded_messages) >= 2
|
|
assert forwarded_messages[0].get("role") == "user"
|
|
assert forwarded_messages[1].get("role") == "assistant"
|
|
|
|
|
|
# ── Generic passthrough (non-Hermes routes) ──────────────────────────────────
|
|
|
|
|
|
@respx.mock
|
|
def test_non_hermes_routes_not_affected() -> None:
|
|
"""Non-Hermes passthrough routes are not touched."""
|
|
app = _make_app()
|
|
with TestClient(app) as client:
|
|
captured = _mock_upstream(respx)
|
|
|
|
response = client.post(
|
|
"/v1/chat/completions",
|
|
headers={"authorization": "Bearer test-key"},
|
|
json={
|
|
"model": "gpt-4o-mini",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Normal passthrough: body should just have "messages"
|
|
assert "messages" in captured["body"]
|
|
|
|
|
|
def test_hermes_adapter_ignores_non_hermes_paths() -> None:
|
|
assert not is_scoped_coding_agent_path("/v1/responses")
|
|
assert not is_scoped_coding_agent_path("/api/codex-proxy/session/v1/chat/completions")
|
|
assert is_scoped_coding_agent_path("/api/codex-proxy/session/v1/responses")
|
|
assert is_scoped_coding_agent_path("/api/claude-code-proxy/session/v1/messages")
|
|
|
|
|
|
def test_hermes_adapter_preserves_structured_messages_when_compressor_changes_output(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def fake_compress(*, messages: list[dict[str, Any]], **_: Any):
|
|
class Result:
|
|
pass
|
|
|
|
result = Result()
|
|
result.messages = [{**message, "content": "compressed"} for message in messages]
|
|
return result
|
|
|
|
monkeypatch.setattr("headroom.compress", fake_compress)
|
|
original = {
|
|
"model": "claude-sonnet-4-5-20250929",
|
|
"messages": [
|
|
{"role": "user", "content": "compress this"},
|
|
{"role": "assistant", "content": [{"type": "tool_use", "name": "read_file"}]},
|
|
{"role": "user", "content": [{"type": "tool_result", "content": "secret"}]},
|
|
],
|
|
}
|
|
body = json.dumps(original).encode()
|
|
|
|
transformed = compress_scoped_passthrough_body(
|
|
"/api/claude-code-proxy/session/v1/messages", body, optimize=True, bypass=False
|
|
)
|
|
|
|
messages = json.loads(transformed)["messages"]
|
|
assert messages[0]["content"] == "compressed"
|
|
assert messages[1] == original["messages"][1]
|
|
assert messages[2] == original["messages"][2]
|
|
|
|
|
|
def test_codex_adapter_compresses_canonical_responses_input_text(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
seen: dict[str, list[dict[str, Any]]] = {}
|
|
|
|
def fake_compress(*, messages: list[dict[str, Any]], **_: Any):
|
|
class Result:
|
|
pass
|
|
|
|
seen["messages"] = messages
|
|
result = Result()
|
|
result.messages = [
|
|
{
|
|
**messages[0],
|
|
"content": [{**messages[0]["content"][0], "text": "compressed prompt"}],
|
|
}
|
|
]
|
|
return result
|
|
|
|
monkeypatch.setattr("headroom.compress", fake_compress)
|
|
original = {
|
|
"model": "gpt-5.5",
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"metadata": {"source": "codex"},
|
|
"content": [{"type": "input_text", "text": "long prompt", "annotations": []}],
|
|
}
|
|
],
|
|
}
|
|
body = json.dumps(original).encode()
|
|
|
|
transformed = compress_scoped_passthrough_body(
|
|
"/api/codex-proxy/session/v1/responses", body, optimize=True, bypass=False
|
|
)
|
|
|
|
assert transformed != body
|
|
assert seen["messages"][0]["type"] == "message"
|
|
assert seen["messages"][0]["content"] == [
|
|
{"type": "text", "text": "long prompt", "annotations": []}
|
|
]
|
|
forwarded = json.loads(transformed)["input"][0]
|
|
assert forwarded == {
|
|
"type": "message",
|
|
"role": "user",
|
|
"metadata": {"source": "codex"},
|
|
"content": [{"type": "input_text", "text": "compressed prompt", "annotations": []}],
|
|
}
|