headroom/tests/conftest.py
chopratejas 967b0db439 fix: B1 — retire ICM, RollingWindow, scoring, relevance + dependents
Phase B step 1 of the live-zone-only realignment. Removes ~10K LOC of
"drop messages from history" machinery that became unreachable after
PR-A1 made `/v1/messages` a passthrough on the proxy. Live-zone-only
compression (PR-B2..B7) operates on content blocks within messages;
message-list mutation no longer happens in the pipeline.

Python deletes:
- headroom/transforms/intelligent_context.py (1077 LOC)
- headroom/transforms/rolling_window.py (395 LOC)
- headroom/transforms/progressive_summarizer.py (508 LOC)
- headroom/transforms/scoring.py (459 LOC)
- headroom/transforms/tool_crusher.py (338 LOC)
- 5 corresponding tests/test_transforms/* and tests/test_proxy_intelligent_context.py

Rust deletes:
- crates/headroom-core/src/context/* (manager, config, workspace,
  candidate, ccr_drop, strategy/, mod) + safety.rs replaced
- crates/headroom-core/src/scoring/* (mod, score, scorer, traits, weights)
- MessageScorerComparator from crates/headroom-parity (PR #338/#343
  becomes deletable; sunk cost stays sunk)
- 13 message_scorer fixtures + record_message_scorer.py

Rust adds (move + rewrite):
- crates/headroom-core/src/transforms/safety.rs — `tool_pair_indices`
  preserves the OpenAI/Anthropic tool_use ↔ tool_result pairing rule
  the live-zone dispatcher (PR-B2) needs. No IcmConfig dependency.

Surface refactors:
- HeadroomConfig: drop `tool_crusher`, `rolling_window`,
  `intelligent_context` fields; hoist `output_buffer_tokens` to top
  level (used by client.py).
- ProxyConfig: drop `intelligent_context*` fields.
- `headroom wrap` proxy server: retire IntelligentContextManager
  and RollingWindow imports + branch; pipeline is CacheAligner →
  ContentRouter (smart_routing) or CacheAligner → SmartCrusher
  (legacy).
- CLI: drop `--no-intelligent-context`, `--no-intelligent-scoring`,
  `--no-compress-first` flags.
- LangChain memory integration: rename `_apply_rolling_window` →
  `_apply_compression`, drop RollingWindowConfig dep. Threshold is
  now advisory — B6 will rework the contract.
- TransformPipeline.create_pipeline now takes only cache_aligner_config.
- headroom/__init__.py + headroom/transforms/__init__.py: strip
  exports of deleted symbols.

Bug fixes uncovered by full pytest sweep:
- providers/copilot/wrap.py: `environ or os.environ` collapsed
  empty-dict to falsy → callers passing `environ={}` accidentally
  pulled from os.environ. Use `environ if environ is not None else
  os.environ`.

Test correctness fixes:
- _DummyAnthropicHandler._retry_request gains **_kwargs to match
  the real handler signature post-A8.
- test_ws_http_fallback extracts JSON from `content=` (post-A3
  byte-faithful) rather than the obsolete `json=` kwarg.
- test_ccr_response_handler_extra fixture joins SSE events with
  `\n\n` per spec (post-A8 byte-buffer parser requirement).
- test_proxy_responses_phase_preservation: capture via direct
  handler attached to the named logger, so the assertion is
  order-independent (proxy `_setup_file_logging` flips
  `headroom.propagate=False` once any earlier test triggers it).
- conftest.py autouse fixture resets `headroom.propagate=True`
  before each test as a defensive measure for the same pollution.
- test_wrap_copilot_translated_backend_still_requires_byok:
  monkeypatch.delenv every provider key so the BYOK error
  actually fires.
- test_native_installers: skip when system bash < 4.3 (macOS ships 3.2).
- TestGeminiEmbedContent / TestGeminiBatchEmbedContents:
  pytest.mark.skip — proxy currently has no :embedContent route;
  feature gap, not regression.

Acceptance:
- cargo build --workspace + cargo clippy + cargo fmt --check: green.
- cargo test --workspace --exclude headroom-py: 777 passed.
- pytest: 4892 passed, 240 skipped, 0 failed.
- git grep returns only intentional comments referencing the deletion.

Per-PR-B1 plan: REALIGNMENT/04-phase-B-live-zone.md.
2026-05-02 12:23:17 -07:00

265 lines
7.6 KiB
Python

"""Shared pytest fixtures for Headroom tests."""
# CRITICAL: Must be set before ANY imports that could trigger sentence_transformers
# The Rust tokenizers use parallelism that deadlocks with pytest-asyncio
import os
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import json
import tempfile
from datetime import datetime
from pathlib import Path
from unittest.mock import Mock
import pytest
# Import httpx for timeout handling (will be available since it's a dependency)
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
# =============================================================================
# Global test hooks
# =============================================================================
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
"""Wrap test execution to catch httpx.ReadTimeout and skip instead of fail.
This handles flaky network timeouts that occur when:
- HuggingFace Hub is slow during model downloads (sentence-transformers)
- External embedding APIs timeout
- Network connectivity issues in CI
"""
outcome = yield
if HTTPX_AVAILABLE and outcome.excinfo is not None:
exc_type, exc_value, exc_tb = outcome.excinfo
if isinstance(exc_value, httpx.ReadTimeout):
pytest.skip("Skipped due to network timeout (flaky CI)")
@pytest.fixture(autouse=True)
def _reset_headroom_logger_propagation():
"""Keep `headroom.*` log records flowing to pytest's caplog handler.
`headroom.proxy.helpers._setup_file_logging` sets
``logging.getLogger("headroom").propagate = False`` once any test
triggers a proxy startup with `--log-file`. After that, every
subsequent test's `caplog` fixture stops capturing `headroom.*`
log records (caplog attaches to root, propagation is now blocked
at the headroom-logger boundary). Reset before every test so the
capture is deterministic regardless of run order.
"""
import logging as _logging
_logging.getLogger("headroom").propagate = True
yield
# =============================================================================
# Sample messages fixtures
# =============================================================================
# Sample messages fixtures
@pytest.fixture
def sample_messages():
"""Basic conversation messages."""
return [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
@pytest.fixture
def sample_messages_with_tools():
"""Conversation with tool calls and responses."""
return [
{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": "Search for user 12345"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {"name": "search_user", "arguments": '{"user_id": "12345"}'},
}
],
},
{
"role": "tool",
"tool_call_id": "call_123",
"content": '{"id": "12345", "name": "Alice", "email": "alice@example.com"}',
},
{"role": "assistant", "content": "I found user Alice with ID 12345."},
]
@pytest.fixture
def sample_tool_output_large():
"""Large tool output for compression testing (100 items)."""
return json.dumps(
[
{
"id": i,
"name": f"Item {i}",
"score": i * 0.1,
"status": "active" if i % 2 == 0 else "inactive",
}
for i in range(100)
]
)
@pytest.fixture
def sample_tool_output_with_errors():
"""Tool output containing error items."""
items = [{"id": i, "status": "success"} for i in range(20)]
items[5] = {"id": 5, "status": "error", "message": "Connection refused"}
items[15] = {"id": 15, "status": "failed", "exception": "TimeoutError"}
return json.dumps(items)
@pytest.fixture
def sample_system_prompt_with_date():
"""System prompt containing dynamic date."""
return "You are a helpful assistant. Current date: 2025-01-06. Help the user with their tasks."
@pytest.fixture
def sample_anthropic_messages():
"""Anthropic-style messages with content blocks."""
return [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this image"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "..."},
},
],
}
]
# Mock client fixtures
@pytest.fixture
def mock_openai_response():
"""Mock OpenAI API response."""
mock = Mock()
mock.id = "chatcmpl-123"
mock.model = "gpt-4o"
mock.usage = Mock()
mock.usage.prompt_tokens = 100
mock.usage.completion_tokens = 50
mock.usage.total_tokens = 150
mock.choices = [Mock()]
mock.choices[0].message = Mock()
mock.choices[0].message.content = "This is a response."
mock.choices[0].message.role = "assistant"
mock.choices[0].finish_reason = "stop"
return mock
@pytest.fixture
def mock_openai_client(mock_openai_response):
"""Mock OpenAI client."""
client = Mock()
client.chat = Mock()
client.chat.completions = Mock()
client.chat.completions.create = Mock(return_value=mock_openai_response)
return client
# Storage fixtures
@pytest.fixture
def temp_sqlite_db():
"""Temporary SQLite database path."""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def temp_jsonl_file():
"""Temporary JSONL file path."""
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
# Provider fixtures
@pytest.fixture
def openai_provider():
"""OpenAI provider instance."""
from headroom.providers.openai import OpenAIProvider
return OpenAIProvider()
@pytest.fixture
def openai_tokenizer():
"""OpenAI token counter for gpt-4o."""
from headroom.providers.openai import OpenAITokenCounter
return OpenAITokenCounter("gpt-4o")
# Config fixtures
@pytest.fixture
def default_config():
"""Default HeadroomConfig."""
from headroom.config import HeadroomConfig
return HeadroomConfig()
@pytest.fixture
def smart_crusher_config():
"""SmartCrusher config for testing."""
from headroom.config import SmartCrusherConfig
return SmartCrusherConfig(
enabled=True,
min_items_to_analyze=3,
min_tokens_to_crush=0, # Always crush for tests
max_items_after_crush=10,
)
# Helper for creating RequestMetrics
@pytest.fixture
def sample_request_metrics():
"""Sample RequestMetrics for storage tests."""
from headroom.config import RequestMetrics
return RequestMetrics(
request_id="test-123",
timestamp=datetime(2025, 1, 6, 12, 0, 0),
model="gpt-4o",
stream=False,
mode="audit",
tokens_input_before=1000,
tokens_input_after=800,
tokens_output=200,
block_breakdown={"system": 100, "user": 200, "assistant": 500},
waste_signals={"json_bloat": 50},
stable_prefix_hash="abc123",
cache_alignment_score=85.0,
cached_tokens=100,
transforms_applied=["CacheAligner", "SmartCrusher"],
tool_units_dropped=1,
turns_dropped=0,
messages_hash="def456",
)