mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## 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/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## 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 - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
262 lines
9.2 KiB
Python
262 lines
9.2 KiB
Python
"""Integration tests for the proxy with real Gemini API calls.
|
|
|
|
These tests require a valid GEMINI_API_KEY environment variable.
|
|
They test the actual /v1/chat/completions endpoint with real API calls.
|
|
|
|
Run with:
|
|
GEMINI_API_KEY=your-key pytest tests/test_proxy_gemini_integration.py -v
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
|
|
# Skip entire module if no API key
|
|
pytestmark = pytest.mark.skipif(
|
|
not os.environ.get("GEMINI_API_KEY"), reason="GEMINI_API_KEY not set"
|
|
)
|
|
|
|
pytest.importorskip("fastapi")
|
|
pytest.importorskip("httpx")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
|
|
from tests._gemini_live import skip_if_gemini_quota_exhausted # noqa: E402
|
|
|
|
GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
|
|
|
|
|
@pytest.fixture
|
|
def gemini_client():
|
|
"""Create test client configured to forward to Gemini."""
|
|
config = ProxyConfig(
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
openai_api_url=GEMINI_BASE_URL,
|
|
)
|
|
app = create_app(config)
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def api_key():
|
|
"""Get Gemini API key from environment."""
|
|
return os.environ.get("GEMINI_API_KEY")
|
|
|
|
|
|
class TestGeminiChatCompletions:
|
|
"""Test /v1/chat/completions with real Gemini API."""
|
|
|
|
def test_basic_completion(self, gemini_client, api_key):
|
|
"""Basic chat completion works."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "gemini-2.0-flash",
|
|
"messages": [
|
|
{"role": "user", "content": "What is 2+2? Reply with just the number."}
|
|
],
|
|
},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify OpenAI-compatible response format
|
|
assert "choices" in data
|
|
assert len(data["choices"]) > 0
|
|
assert "message" in data["choices"][0]
|
|
assert "content" in data["choices"][0]["message"]
|
|
assert "4" in data["choices"][0]["message"]["content"]
|
|
|
|
# Verify usage stats
|
|
assert "usage" in data
|
|
assert "prompt_tokens" in data["usage"]
|
|
assert "completion_tokens" in data["usage"]
|
|
|
|
def test_multi_turn_conversation(self, gemini_client, api_key):
|
|
"""Multi-turn conversations maintain context."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "gemini-2.0-flash",
|
|
"messages": [
|
|
{"role": "system", "content": "You are a helpful assistant. Be concise."},
|
|
{"role": "user", "content": "My name is TestUser123."},
|
|
{"role": "assistant", "content": "Nice to meet you, TestUser123!"},
|
|
{"role": "user", "content": "What is my name?"},
|
|
],
|
|
},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
content = data["choices"][0]["message"]["content"].lower()
|
|
assert "testuser123" in content
|
|
|
|
def test_streaming(self, gemini_client, api_key):
|
|
"""Streaming responses work correctly."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "gemini-2.0-flash",
|
|
"stream": True,
|
|
"messages": [{"role": "user", "content": "Count from 1 to 3."}],
|
|
},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
assert response.status_code == 200
|
|
|
|
# Parse SSE stream
|
|
chunks = []
|
|
for line in response.text.strip().split("\n"):
|
|
if line.startswith("data: ") and line != "data: [DONE]":
|
|
chunk = json.loads(line[6:])
|
|
chunks.append(chunk)
|
|
|
|
assert len(chunks) > 0
|
|
|
|
# Verify chunk format
|
|
for chunk in chunks:
|
|
assert "choices" in chunk
|
|
assert "delta" in chunk["choices"][0]
|
|
assert chunk["object"] == "chat.completion.chunk"
|
|
|
|
def test_function_calling(self, gemini_client, api_key):
|
|
"""Function calling / tools work correctly."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "gemini-2.0-flash",
|
|
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get the weather for a location",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"location": {"type": "string", "description": "City name"}
|
|
},
|
|
"required": ["location"],
|
|
},
|
|
},
|
|
}
|
|
],
|
|
"tool_choice": "auto",
|
|
},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Verify tool call response
|
|
message = data["choices"][0]["message"]
|
|
assert "tool_calls" in message
|
|
assert len(message["tool_calls"]) > 0
|
|
|
|
tool_call = message["tool_calls"][0]
|
|
assert tool_call["function"]["name"] == "get_weather"
|
|
assert "paris" in tool_call["function"]["arguments"].lower()
|
|
|
|
def test_json_mode(self, gemini_client, api_key):
|
|
"""JSON response format works."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "gemini-2.0-flash",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "Return a JSON object with keys 'name' and 'age' for a person named Alice who is 30 years old.",
|
|
}
|
|
],
|
|
"response_format": {"type": "json_object"},
|
|
},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
content = data["choices"][0]["message"]["content"]
|
|
# Parse the response as JSON
|
|
parsed = json.loads(content)
|
|
assert "name" in parsed or "Name" in parsed
|
|
assert "age" in parsed or "Age" in parsed
|
|
|
|
|
|
class TestGeminiModels:
|
|
"""Test /v1/models endpoint with Gemini."""
|
|
|
|
def test_list_models(self, gemini_client, api_key):
|
|
"""Can list available models."""
|
|
response = gemini_client.get("/v1/models", headers={"Authorization": f"Bearer {api_key}"})
|
|
skip_if_gemini_quota_exhausted(response)
|
|
# This goes through passthrough handler
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
assert "data" in data or "object" in data
|
|
|
|
|
|
class TestProxyStats:
|
|
"""Test that proxy stats track Gemini requests correctly."""
|
|
|
|
def test_stats_track_requests(self, gemini_client, api_key):
|
|
"""Proxy stats track Gemini requests."""
|
|
# Make a request
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hi"}]},
|
|
)
|
|
skip_if_gemini_quota_exhausted(response)
|
|
|
|
# Check stats
|
|
stats_response = gemini_client.get("/stats")
|
|
assert stats_response.status_code == 200
|
|
stats = stats_response.json()
|
|
|
|
assert stats["requests"]["total"] >= 1
|
|
assert stats["requests"]["by_provider"]["openai"] >= 1
|
|
assert "gemini" in str(stats["requests"]["by_model"]).lower()
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Test error handling with Gemini."""
|
|
|
|
def test_invalid_api_key(self, gemini_client):
|
|
"""Invalid API key returns appropriate error."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": "Bearer invalid-key-123"},
|
|
json={"model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "Hi"}]},
|
|
)
|
|
# Should return 4xx error
|
|
assert response.status_code >= 400
|
|
|
|
def test_invalid_model(self, gemini_client, api_key):
|
|
"""Invalid model returns appropriate error."""
|
|
response = gemini_client.post(
|
|
"/v1/chat/completions",
|
|
headers={"Authorization": f"Bearer {api_key}"},
|
|
json={
|
|
"model": "nonexistent-model-xyz",
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
},
|
|
)
|
|
# Should return 4xx error
|
|
assert response.status_code >= 400
|