0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""Tests for the /v1/compress endpoint in the proxy server.
|
|
|
|
These tests verify that the compression-only endpoint works correctly
|
|
for the TypeScript SDK and other HTTP clients.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
# Skip if fastapi not available
|
|
pytest.importorskip("fastapi")
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from headroom.proxy.server import ProxyConfig, create_app
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Create test client with optimization enabled."""
|
|
config = ProxyConfig(
|
|
optimize=True,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
# /v1/compress is loopback-gated (#1227).
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_optimize():
|
|
"""Create test client with optimization disabled."""
|
|
config = ProxyConfig(
|
|
optimize=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
# /v1/compress is loopback-gated (#1227).
|
|
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as c:
|
|
yield c
|
|
|
|
|
|
class TestCompressEndpointValidation:
|
|
"""Test request validation for /v1/compress."""
|
|
|
|
def test_missing_messages_returns_400(self, client):
|
|
"""Request without messages field should return 400."""
|
|
response = client.post("/v1/compress", json={"model": "gpt-4"})
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "error" in data
|
|
assert data["error"]["type"] == "invalid_request"
|
|
assert "messages" in data["error"]["message"]
|
|
|
|
def test_missing_model_returns_400(self, client):
|
|
"""Request without model field should return 400."""
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "error" in data
|
|
assert data["error"]["type"] == "invalid_request"
|
|
assert "model" in data["error"]["message"]
|
|
|
|
def test_invalid_json_returns_400(self, client):
|
|
"""Request with invalid JSON should return 400."""
|
|
response = client.post(
|
|
"/v1/compress",
|
|
content=b"not valid json",
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert data["error"]["type"] == "invalid_request"
|
|
|
|
|
|
class TestCompressEndpointBasic:
|
|
"""Test basic compress endpoint behavior."""
|
|
|
|
def test_empty_messages_returns_empty(self, client):
|
|
"""Empty messages list should return as-is with zero metrics."""
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={"messages": [], "model": "gpt-4"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["messages"] == []
|
|
assert data["tokens_before"] == 0
|
|
assert data["tokens_after"] == 0
|
|
assert data["tokens_saved"] == 0
|
|
assert data["compression_ratio"] == 1.0
|
|
assert data["transforms_applied"] == []
|
|
assert data["ccr_hashes"] == []
|
|
|
|
def test_basic_compression_response_shape(self, client):
|
|
"""Verify the response contains all expected fields."""
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={
|
|
"messages": [{"role": "user", "content": "Hello, world!"}],
|
|
"model": "gpt-4",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Check all expected fields are present
|
|
assert "messages" in data
|
|
assert "tokens_before" in data
|
|
assert "tokens_after" in data
|
|
assert "tokens_saved" in data
|
|
assert "compression_ratio" in data
|
|
assert "transforms_applied" in data
|
|
assert "ccr_hashes" in data
|
|
|
|
# Messages should be a list
|
|
assert isinstance(data["messages"], list)
|
|
assert len(data["messages"]) >= 1
|
|
|
|
# Numeric fields should be non-negative
|
|
assert data["tokens_before"] >= 0
|
|
assert data["tokens_after"] >= 0
|
|
assert data["tokens_saved"] >= 0
|
|
assert data["compression_ratio"] > 0
|
|
|
|
def test_bypass_header_returns_uncompressed(self, client):
|
|
"""X-Headroom-Bypass header should skip compression."""
|
|
messages = [
|
|
{"role": "user", "content": "Hello"},
|
|
{"role": "assistant", "content": "Hi there!"},
|
|
{"role": "user", "content": "How are you?"},
|
|
]
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={"messages": messages, "model": "gpt-4"},
|
|
headers={"x-headroom-bypass": "true"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["messages"] == messages
|
|
assert data["tokens_before"] == 0
|
|
assert data["tokens_after"] == 0
|
|
assert data["tokens_saved"] == 0
|
|
assert data["compression_ratio"] == 1.0
|
|
assert data["transforms_applied"] == []
|
|
assert data["ccr_hashes"] == []
|
|
|
|
def test_bypass_header_case_insensitive(self, client):
|
|
"""Bypass header should be case-insensitive."""
|
|
messages = [{"role": "user", "content": "Hello"}]
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={"messages": messages, "model": "gpt-4"},
|
|
headers={"x-headroom-bypass": "TRUE"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["messages"] == messages
|
|
|
|
|
|
class TestCompressEndpointCompression:
|
|
"""Test that actual compression happens for large content."""
|
|
|
|
def test_large_tool_output_gets_compressed(self, client):
|
|
"""Large tool output content should result in tokens_saved > 0."""
|
|
# Create a large repetitive tool output that should be compressible
|
|
large_data = json.dumps(
|
|
[
|
|
{
|
|
"id": i,
|
|
"name": f"Item {i}",
|
|
"description": f"This is a detailed description for item number {i}. "
|
|
f"It contains various attributes and metadata that are typical "
|
|
f"of API responses. The item has a status of active and was "
|
|
f"created on 2024-01-{(i % 28) + 1:02d}. Additional fields "
|
|
f"include category=electronics, price={i * 10.99:.2f}, "
|
|
f"rating={4.0 + (i % 10) / 10:.1f}, stock={i * 5}.",
|
|
"tags": ["electronics", "sale", "featured", "new-arrival"],
|
|
"metadata": {
|
|
"created_by": "system",
|
|
"updated_at": "2024-01-15T00:00:00Z",
|
|
"version": i,
|
|
"source": "api",
|
|
},
|
|
}
|
|
for i in range(200)
|
|
]
|
|
)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "What items are available?"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_123",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_items",
|
|
"arguments": "{}",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_123",
|
|
"content": large_data,
|
|
},
|
|
{"role": "user", "content": "Summarize the first 5 items."},
|
|
]
|
|
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={"messages": messages, "model": "gpt-4"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# With a large tool output, the pipeline should process successfully
|
|
assert data["tokens_before"] > 0
|
|
assert data["tokens_after"] > 0
|
|
assert data["tokens_after"] <= data["tokens_before"]
|
|
assert data["tokens_saved"] == data["tokens_before"] - data["tokens_after"]
|
|
assert 0 < data["compression_ratio"] <= 1.0
|
|
assert isinstance(data["transforms_applied"], list)
|
|
|
|
def test_small_content_may_not_compress(self, client):
|
|
"""Small messages may not get compressed but should still work."""
|
|
response = client.post(
|
|
"/v1/compress",
|
|
json={
|
|
"messages": [{"role": "user", "content": "Hi"}],
|
|
"model": "gpt-4",
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Should still return valid response regardless of compression
|
|
assert data["tokens_before"] >= 0
|
|
assert data["tokens_after"] >= 0
|
|
assert isinstance(data["transforms_applied"], list)
|
|
|
|
|
|
class TestCompressEndpointDoesNotBlockLoop:
|
|
"""/v1/compress must offload to the compression executor so a slow/large
|
|
payload cannot freeze the single event loop (#718)."""
|
|
|
|
async def test_compress_does_not_block_liveness(self, monkeypatch):
|
|
import asyncio
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
config = ProxyConfig(
|
|
optimize=True,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
)
|
|
app = create_app(config)
|
|
proxy = app.state.proxy
|
|
|
|
entered = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def blocking_apply(**kwargs):
|
|
# Stand in for a large CPU-bound compression: blocks its worker until
|
|
# released. If this ran inline on the loop (the bug), the loop would
|
|
# be frozen and /livez below could not be served.
|
|
entered.set()
|
|
release.wait(timeout=10)
|
|
return SimpleNamespace(
|
|
messages=kwargs["messages"],
|
|
tokens_before=10,
|
|
tokens_after=5,
|
|
transforms_applied=[],
|
|
transforms_summary={},
|
|
markers_inserted=[],
|
|
)
|
|
|
|
monkeypatch.setattr(proxy.openai_pipeline, "apply", blocking_apply)
|
|
|
|
# /v1/compress is loopback-gated (#1227) — present as 127.0.0.1.
|
|
transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 12345))
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
|
compress = asyncio.create_task(
|
|
client.post(
|
|
"/v1/compress",
|
|
json={
|
|
"messages": [{"role": "user", "content": "hello world"}],
|
|
"model": "gpt-4",
|
|
},
|
|
)
|
|
)
|
|
# Wait until the compression is actually in flight (running in the
|
|
# executor thread), then prove the loop is still responsive.
|
|
for _ in range(200):
|
|
if entered.is_set():
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
assert entered.is_set(), "compression never started"
|
|
|
|
livez = await asyncio.wait_for(client.get("/livez"), timeout=5)
|
|
assert livez.status_code == 200
|
|
assert livez.json()["alive"] is True
|
|
# The compression is still blocked — /livez was served concurrently.
|
|
assert not compress.done()
|
|
|
|
release.set()
|
|
resp = await asyncio.wait_for(compress, timeout=5)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["tokens_saved"] == 5
|