headroom/tests/conftest.py
Serge ARADJ d7a8cdbee1
feat(proxy): label GitHub Copilot traffic as "copilot" in the outcome… (#2377)
## Description

Requests routed to the GitHub Copilot API travel on the OpenAI or
Anthropic
wire, so the proxy handlers stamp the *wire* provider (`openai` /
`anthropic`)
on the outcome. As a result, Copilot traffic is attributed to
OpenAI/Claude in
the dashboard's per-request provider stats, hiding the real upstream.
(This is
distinct from the existing **Copilot Quota** panel, which is separate
from
per-request provider attribution.)

This labels Copilot traffic as `copilot` in the single outcome funnel.
`build_copilot_upstream_url()` is already the one routing chokepoint
every
Copilot surface goes through (OpenAI `/chat/completions` + `/responses`
and the
Anthropic `/v1/messages` route all build their upstream URL there), so
it flags
the request via a task-local `ContextVar`; `emit_request_outcome()`
reads the
flag and relabels the provider. The relabel runs before the `>= 500`
failed
guard, so a failed Copilot request is attributed to `copilot` too.
Non-Copilot
traffic never sets the flag and is untouched.

## Type of Change

- [x] New feature (non-breaking change that adds functionality)

## Changes Made

- `headroom/copilot_auth.py`: add a task-local
`_request_routed_to_copilot`
`ContextVar` with `mark_request_routed_to_copilot()` /
`request_routed_to_copilot()`
helpers; set the flag in `build_copilot_upstream_url()` whenever the
base is a
  Copilot API URL (the existing `is_copilot_api_url` check). `/v1` path
  normalization is unchanged.
- `headroom/proxy/outcome.py`: in `emit_request_outcome()`, when the
request was
routed to Copilot and the wire provider is `openai`/`anthropic`, relabel
the
  outcome provider to `copilot` (before the 5xx guard).
- `tests/test_copilot_provider_label.py`: new tests for the chokepoint
marking
  and the outcome relabel.

## Testing

- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`) — ran on the changed files only
(clean)
- [ ] Type checking passes (`mypy headroom`) — ran on the changed files
only (clean)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
$ python -m pytest tests/test_copilot_provider_label.py tests/test_outcome_records_5xx_as_failed.py -q
tests/test_copilot_provider_label.py .....                               [ 71%]
tests/test_outcome_records_5xx_as_failed.py ..                           [100%]
7 passed

$ python -m pytest tests/test_copilot_auth.py -k "build_copilot_upstream_url or copilot_api_url" -q
8 passed, 58 deselected      # existing /v1-stripping behavior preserved

$ python -m ruff check headroom/copilot_auth.py headroom/proxy/outcome.py tests/test_copilot_provider_label.py
All checks passed!

$ python -m mypy headroom/copilot_auth.py headroom/proxy/outcome.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: Python 3.11, headroom installed with the `proxy` extra.
- Exact command / steps: the unit tests above drive
`build_copilot_upstream_url()`
followed by `emit_request_outcome()` in an isolated context and assert
the
  recorded provider.
- Observed result: an `anthropic`/`openai` outcome for a request routed
to
`https://api.githubcopilot.com` is recorded as provider `copilot`; a
request
  not routed to Copilot is recorded under its wire provider unchanged.
- Not tested: end-to-end against a live Copilot subscription (no live
seat in the
  test environment).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Additional Notes

- The flag is a `ContextVar` (task-local), so it cannot bleed across
concurrent
requests; each request that is not routed to Copilot simply reads the
`False`
  default.
- No `CHANGELOG.md` edits (release-please generates it from the
Conventional
  Commit PR title).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-18 09:54:06 -07:00

306 lines
9.8 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
from tests._skip_helpers import external_model_skip_reason
# A live `headroom` dev session exports HEADROOM_* into the shell (and the
# Claude wrap adds ANTHROPIC_CUSTOM_HEADERS). Click `envvar=` options pick
# those up inside CliRunner, so assertions would see the developer's proxy
# config instead of the test's. Scrub them so local runs match CI; tests
# that need a value set it explicitly via monkeypatch or CliRunner env.
@pytest.fixture(autouse=True)
def _scrub_developer_headroom_env(monkeypatch):
for key in list(os.environ):
if key.startswith("HEADROOM_"):
monkeypatch.delenv(key, raising=False)
monkeypatch.delenv("ANTHROPIC_CUSTOM_HEADERS", raising=False)
# The Copilot "routed to Copilot" flag is a module-global ContextVar that
# build_copilot_upstream_url() sets as a side effect. Unit tests that call that
# builder directly (or otherwise run in the shared root context) would leave it
# set and mislabel a later test's request outcome as "copilot". Reset it around
# every test so build-time side effects can't leak between tests.
@pytest.fixture(autouse=True)
def _reset_copilot_routing_flag():
from headroom.copilot_auth import reset_request_routed_to_copilot
reset_request_routed_to_copilot()
yield
reset_request_routed_to_copilot()
# =============================================================================
# Global test hooks
# =============================================================================
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
"""Wrap test execution to skip transient or offline external model failures.
This handles model-loading failures that occur when:
- HuggingFace Hub is slow during model downloads (sentence-transformers)
- Required HuggingFace model files were not restored into the offline CI cache
- External embedding APIs timeout
- Network connectivity issues in CI
"""
outcome = yield
if outcome.excinfo is not None:
exc_type, exc_value, exc_tb = outcome.excinfo
reason = external_model_skip_reason(exc_value)
if reason is not None:
pytest.skip(reason)
@pytest.fixture(autouse=True)
def _reset_headroom_logger_propagation():
"""Keep `headroom.*` log records flowing to pytest's caplog handler.
Two sources disable propagation on the headroom logger tree and never
restore it, which then makes later `caplog`-based assertions flaky in
full-suite runs (caplog attaches to root, so a `propagate=False` anywhere
on the chain silently drops the records):
- ``headroom.proxy.helpers._setup_file_logging`` sets
``getLogger("headroom").propagate = False`` on proxy startup.
- ``benchmarks.claude_session_mode_benchmark._disable_headroom_benchmark_logging``
(exercised by ``test_claude_session_mode_benchmark``) sets
``propagate = False`` + ``CRITICAL`` on ``headroom``, ``headroom.proxy``,
``headroom.transforms``, ``headroom.cache`` (and children).
Resetting only ``"headroom"`` is not enough — a child like
``"headroom.proxy"`` left non-propagating blocks the record before it
reaches root. Reset the whole subtree before every test so capture is
deterministic regardless of run order.
"""
import logging as _logging
for _name in ("headroom", *list(_logging.root.manager.loggerDict)):
if _name == "headroom" or _name.startswith("headroom."):
logger = _logging.getLogger(_name)
logger.disabled = False
# The benchmark also raises the level to CRITICAL; children
# inherit it (effective level), so a WARNING would be filtered
# at the logger before it can propagate to caplog. Reset to
# NOTSET so the subtree inherits root's level deterministically.
logger.setLevel(_logging.NOTSET)
logger.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",
)