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
1144 lines
46 KiB
Python
1144 lines
46 KiB
Python
"""Wave 3 — multi-turn live integration tests for the Phase A+B realignment.
|
|
|
|
These tests guard the load-bearing claims of the megamerge branch
|
|
(``realign-phase-AB-cache-safety-and-live-zone``) against real upstream
|
|
APIs. They are OPT-IN: each test skips cleanly when the relevant provider
|
|
key is missing, and the file is gated by ``pytest.mark.live`` so the
|
|
default ``pytest -m "not live"`` run never touches the network.
|
|
|
|
Each test maps to one or more A+B PR claims:
|
|
|
|
* ``test_anthropic_cache_hit_across_two_turns`` — A2/A6/E (cache hot zone)
|
|
* ``test_anthropic_cache_stable_when_live_zone_compresses`` — B2/B3
|
|
* ``test_anthropic_cache_control_passthrough_byte_faithful`` — A3/A4
|
|
* ``test_openai_chat_completions_multi_turn_through_proxy`` — A8/B
|
|
* ``test_openai_streaming_sse_chunks_arrive_in_order`` — A8 (SSE wire)
|
|
* ``test_gemini_multi_turn_through_proxy`` — Gemini handler reachability
|
|
* ``test_ccr_marker_round_trip_live`` — B7 (CCR persistence)
|
|
* ``test_memory_tail_injection_does_not_modify_system_prompt_live`` — B6/A2
|
|
* ``test_classify_auth_mode_routes_payg_vs_oauth`` — Phase F-prep / B5
|
|
|
|
Run with::
|
|
|
|
python -m pytest tests/test_realignment_live_multi_turn.py -v
|
|
|
|
CI surrogate (must stay green and excludes this file)::
|
|
|
|
python -m pytest -m "not live" --tb=short -q
|
|
|
|
Per-realignment-plan: ``REALIGNMENT/04-phase-B-live-zone.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
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._dotenv import autouse_apply_env, load_env_overrides # noqa: E402
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module-level config
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# All thresholds and model IDs configurable here so the tests stay free of
|
|
# hardcoded magic numbers (per-realignment build constraint:
|
|
# ``feedback_realignment_build_constraints.md``).
|
|
|
|
LIVE_CONFIG: dict[str, Any] = {
|
|
# Anthropic — primary model with explicit fallback. The fallback path
|
|
# only triggers if the primary returns 4xx for "model not found".
|
|
"anthropic_model_primary": os.environ.get("HEADROOM_LIVE_ANTHROPIC_MODEL", "claude-sonnet-4-5"),
|
|
"anthropic_model_fallback": "claude-3-5-sonnet-20241022",
|
|
"anthropic_version": "2023-06-01",
|
|
# OpenAI — small + cheap.
|
|
"openai_model": os.environ.get("HEADROOM_LIVE_OPENAI_MODEL", "gpt-4o-mini"),
|
|
# Gemini — flash variants.
|
|
"gemini_model": os.environ.get("HEADROOM_LIVE_GEMINI_MODEL", "gemini-2.0-flash"),
|
|
# Token budgets — keep small so each test stays under 30s.
|
|
"max_tokens_short": 32,
|
|
"max_tokens_med": 128,
|
|
# Cache prefix size — must exceed Anthropic's prompt-cache minimum
|
|
# (~1024 tokens for Sonnet/Opus). We pad with deterministic English
|
|
# sentences so each turn is byte-identical.
|
|
"cache_prefix_min_chars": 6000,
|
|
# Live-zone tool_result payload — must be large enough that ContentRouter
|
|
# routes it through SmartCrusher (default min_tokens_to_crush=500). 8KB
|
|
# of JSON dicts gets us comfortably above that.
|
|
"live_zone_tool_result_items": 200,
|
|
# CCR test payload — same shape, bigger.
|
|
"ccr_tool_result_items": 250,
|
|
# Memory test fixtures.
|
|
"memory_user_id": "wave3-test-user",
|
|
"memory_seed_text": ("User prefers tabs over spaces. User's favorite color is teal."),
|
|
}
|
|
|
|
_env_overrides = load_env_overrides()
|
|
ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY") or _env_overrides.get("ANTHROPIC_API_KEY", "")
|
|
OPENAI_KEY = os.environ.get("OPENAI_API_KEY") or _env_overrides.get("OPENAI_API_KEY", "")
|
|
GEMINI_KEY = os.environ.get("GEMINI_API_KEY") or _env_overrides.get("GEMINI_API_KEY", "")
|
|
|
|
apply_dotenv = autouse_apply_env(_env_overrides)
|
|
|
|
pytestmark = pytest.mark.live
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def proxy_client() -> Iterator[TestClient]:
|
|
"""Module-scoped proxy client with live-zone compression enabled.
|
|
|
|
The default config exercises the full A+B pipeline:
|
|
* ``optimize=True`` — live-zone block dispatcher runs (B2/B3)
|
|
* ``ccr_inject_tool=True`` — CCR persists + tool injected (B7)
|
|
* cache/rate-limit disabled — keep tests deterministic
|
|
"""
|
|
config = ProxyConfig(
|
|
optimize=True,
|
|
ccr_inject_tool=True,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
)
|
|
app = create_app(config)
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
def _build_cache_prefix() -> str:
|
|
"""Return a deterministic, ~6KB English string suitable for a
|
|
cache-control prefix. Two identical calls produce identical bytes
|
|
so the prefix hashes identically across turns.
|
|
"""
|
|
seed = (
|
|
"Headroom is a context-engineering layer for LLM applications. "
|
|
"It compresses tool outputs, aligns prefix caches, and routes content "
|
|
"to specialized compressors. The realignment branch (Phase A+B) hardens "
|
|
"cache stability and constrains compression to the live zone — the "
|
|
"latest user message tail. Frozen prefixes are never mutated. "
|
|
)
|
|
out = []
|
|
target = LIVE_CONFIG["cache_prefix_min_chars"]
|
|
while sum(len(s) for s in out) < target:
|
|
out.append(seed)
|
|
return "".join(out)
|
|
|
|
|
|
def _anthropic_call(
|
|
client: TestClient,
|
|
*,
|
|
api_key: str,
|
|
body: dict[str, Any],
|
|
extra_headers: dict[str, str] | None = None,
|
|
) -> httpx.Response:
|
|
"""POST /v1/messages through the proxy with an Anthropic API key."""
|
|
headers = {
|
|
"x-api-key": api_key,
|
|
"anthropic-version": LIVE_CONFIG["anthropic_version"],
|
|
"content-type": "application/json",
|
|
}
|
|
if extra_headers:
|
|
headers.update(extra_headers)
|
|
return client.post("/v1/messages", headers=headers, json=body)
|
|
|
|
|
|
def _resolve_anthropic_model(client: TestClient, api_key: str) -> str:
|
|
"""Pick the first Anthropic model that responds 200 to a tiny ping.
|
|
|
|
Falls back to the explicit secondary if the primary 4xx's. Avoids
|
|
"model not enabled on this key" failures derailing the whole suite.
|
|
"""
|
|
for model_id in (
|
|
LIVE_CONFIG["anthropic_model_primary"],
|
|
LIVE_CONFIG["anthropic_model_fallback"],
|
|
):
|
|
resp = _anthropic_call(
|
|
client,
|
|
api_key=api_key,
|
|
body={
|
|
"model": model_id,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": [{"role": "user", "content": "ping"}],
|
|
},
|
|
)
|
|
if resp.status_code == 200:
|
|
return model_id
|
|
pytest.skip("neither primary nor fallback Anthropic model accepted by this key")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1) Anthropic prompt-cache works across two turns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set")
|
|
def test_anthropic_cache_hit_across_two_turns(proxy_client: TestClient) -> None:
|
|
"""A2/A6/E — sending identical cache_control'd system+messages twice
|
|
must produce a cache_read_input_tokens > 0 on turn 2.
|
|
|
|
The proxy's cache hot zone (system prompt + frozen prefix) is
|
|
invariant I2 from the realignment plan — if compression mutates any
|
|
of those bytes, this hit count goes to 0 and the test fails.
|
|
"""
|
|
model = _resolve_anthropic_model(proxy_client, ANTHROPIC_KEY)
|
|
cache_prefix = _build_cache_prefix()
|
|
|
|
# NB: user content is sent as a list-of-content-blocks (not a bare
|
|
# string) to match the shape production clients (Claude Code, Codex)
|
|
# send and to keep the live-zone block dispatcher's traversal path
|
|
# consistent across turns. Bare-string content hits a separate
|
|
# normalization path; both should be cache-stable, but list form is
|
|
# what the realignment lockdown was tuned against.
|
|
body_template: dict[str, Any] = {
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"system": [
|
|
{
|
|
"type": "text",
|
|
"text": cache_prefix,
|
|
"cache_control": {"type": "ephemeral"},
|
|
}
|
|
],
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Reply with the single word OK."}],
|
|
}
|
|
],
|
|
}
|
|
|
|
# Turn 1: either creates the cache (first run after a cold start) or
|
|
# reads from a cache populated by a prior run with the same prefix
|
|
# (re-runs within Anthropic's 5-minute ephemeral TTL). Either way,
|
|
# turn 1's bytes get the prefix into a HOT cache slot.
|
|
resp1 = _anthropic_call(proxy_client, api_key=ANTHROPIC_KEY, body=body_template)
|
|
assert resp1.status_code == 200, resp1.text
|
|
usage1 = resp1.json()["usage"]
|
|
cache_create_t1 = usage1.get("cache_creation_input_tokens", 0)
|
|
cache_read_t1 = usage1.get("cache_read_input_tokens", 0)
|
|
assert cache_create_t1 > 0 or cache_read_t1 > 0, (
|
|
"Anthropic did neither cache-create nor cache-read on turn 1 — "
|
|
f"prefix likely below the per-model minimum. usage={usage1}"
|
|
)
|
|
|
|
# Anthropic's prompt cache write is eventually consistent: a turn-2
|
|
# read immediately after turn-1's write occasionally returns 0
|
|
# cache_read tokens even though the bytes are byte-identical. Retry up
|
|
# to N times before declaring the cache broken — this isolates "proxy
|
|
# broke cache stability" (always 0) from "Anthropic write hadn't
|
|
# propagated yet" (eventually > 0). The realignment claim is cache
|
|
# STABILITY across turns, not first-turn-write latency.
|
|
last_usage: dict[str, Any] = {}
|
|
cache_read_observed = 0
|
|
max_retries = 4
|
|
for _attempt in range(max_retries):
|
|
respN = _anthropic_call(proxy_client, api_key=ANTHROPIC_KEY, body=body_template)
|
|
assert respN.status_code == 200, respN.text
|
|
last_usage = respN.json()["usage"]
|
|
cache_read_observed = last_usage.get("cache_read_input_tokens", 0)
|
|
if cache_read_observed > 0:
|
|
break
|
|
|
|
# The strong claim: a subsequent identical request reads SOMETHING
|
|
# from cache. Direction, not exact numbers (per-spec: no token-saved
|
|
# deltas tied to upstream drift).
|
|
assert cache_read_observed > 0, (
|
|
f"expected cache_read_input_tokens > 0 within {max_retries} retries; "
|
|
f"turn1_create={cache_create_t1} turn1_read={cache_read_t1} "
|
|
f"last_read={cache_read_observed} "
|
|
f"turn1_input={usage1.get('input_tokens')} "
|
|
f"last_input={last_usage.get('input_tokens')} "
|
|
f"last_create={last_usage.get('cache_creation_input_tokens')}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2) Live-zone compression doesn't disturb cached prefix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set")
|
|
def test_anthropic_cache_stable_when_live_zone_compresses(
|
|
proxy_client: TestClient,
|
|
) -> None:
|
|
"""B2/B3 — turn 2 mutates only the LATEST user message (a fresh tool_result
|
|
block large enough to compress). The cached prefix must still hit.
|
|
|
|
Asserts:
|
|
(a) cache_read_input_tokens > 0 on turn 2 (prefix preserved)
|
|
(b) the proxy emitted a compression header — proving the live-zone
|
|
dispatcher actually ran on the new tail block.
|
|
"""
|
|
model = _resolve_anthropic_model(proxy_client, ANTHROPIC_KEY)
|
|
cache_prefix = _build_cache_prefix()
|
|
|
|
base_system = [
|
|
{
|
|
"type": "text",
|
|
"text": cache_prefix,
|
|
"cache_control": {"type": "ephemeral"},
|
|
}
|
|
]
|
|
|
|
# Turn 1 — small conversation. Warms the cache.
|
|
body1 = {
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"system": base_system,
|
|
"messages": [
|
|
{"role": "user", "content": "Reply with the single word ACK."},
|
|
],
|
|
}
|
|
resp1 = _anthropic_call(proxy_client, api_key=ANTHROPIC_KEY, body=body1)
|
|
assert resp1.status_code == 200, resp1.text
|
|
|
|
# Turn 2 — same prefix + a NEW large user content carrying a JSON-ish
|
|
# payload. ContentRouter should route this through the live-zone
|
|
# compressor. Anthropic doesn't accept arbitrary tool_result on a
|
|
# standalone turn (it must follow a prior tool_use), so we put the
|
|
# payload in a text block on the latest user turn — same load-bearing
|
|
# claim, same code path through the live-zone block dispatcher.
|
|
big_payload = json.dumps(
|
|
[
|
|
{
|
|
"id": i,
|
|
"name": f"item-{i}",
|
|
"score": float(i) * 0.13,
|
|
"tags": ["alpha", "beta", "gamma"],
|
|
"metadata": {"source": "fixture", "version": 1},
|
|
}
|
|
for i in range(LIVE_CONFIG["live_zone_tool_result_items"])
|
|
]
|
|
)
|
|
body2 = {
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"system": base_system,
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": (
|
|
"Here is a JSON dataset:\n```json\n"
|
|
f"{big_payload}\n```\n"
|
|
"Reply with the single word DONE."
|
|
),
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
resp2 = _anthropic_call(proxy_client, api_key=ANTHROPIC_KEY, body=body2)
|
|
assert resp2.status_code == 200, resp2.text
|
|
|
|
usage2 = resp2.json()["usage"]
|
|
cache_read_t2 = usage2.get("cache_read_input_tokens", 0)
|
|
assert cache_read_t2 > 0, (
|
|
f"live-zone tail mutation broke the cached prefix; cache_read=0 usage={usage2}"
|
|
)
|
|
|
|
# Compression header presence proves the live-zone block dispatcher ran.
|
|
# (Proxy emits x-headroom-tokens-{before,after,saved} on every optimized
|
|
# /v1/messages response; we assert direction, not magnitude.)
|
|
tokens_before = resp2.headers.get("x-headroom-tokens-before")
|
|
tokens_after = resp2.headers.get("x-headroom-tokens-after")
|
|
assert tokens_before is not None, "proxy did not emit x-headroom-tokens-before"
|
|
assert tokens_after is not None, "proxy did not emit x-headroom-tokens-after"
|
|
assert int(tokens_before) >= int(tokens_after), (
|
|
f"tokens_after ({tokens_after}) > tokens_before ({tokens_before}) — "
|
|
"live-zone compression should never inflate"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3) cache_control passthrough is byte-faithful (no spurious mutation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set")
|
|
def test_anthropic_cache_control_passthrough_byte_faithful(
|
|
proxy_client: TestClient,
|
|
) -> None:
|
|
"""A3/A4 — when no compression is needed, the bytes the proxy forwards
|
|
upstream must be byte-identical to what the client sent (modulo the
|
|
proxy's own internal x-headroom-* header strip and the Authorization
|
|
rewrite).
|
|
|
|
We exercise this by intercepting the proxy's ``_retry_request`` (the
|
|
final dispatcher to upstream) and snapshotting the body it would have
|
|
sent. cache_control on a content block must be preserved verbatim.
|
|
"""
|
|
model = _resolve_anthropic_model(proxy_client, ANTHROPIC_KEY)
|
|
|
|
cache_prefix = _build_cache_prefix()
|
|
body = {
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"system": [
|
|
{
|
|
"type": "text",
|
|
"text": cache_prefix,
|
|
"cache_control": {"type": "ephemeral"},
|
|
}
|
|
],
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Reply with OK."},
|
|
],
|
|
}
|
|
],
|
|
}
|
|
|
|
# Snapshot the upstream-bound body via a one-shot wrapper that
|
|
# short-circuits before httpx fires. This is the closest thing to a
|
|
# "request-recording helper" the proxy exposes today; httpx_mock /
|
|
# respx would require app-level injection that the TestClient
|
|
# transport doesn't surface.
|
|
proxy = proxy_client.app.state.proxy
|
|
original_retry = proxy._retry_request
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def _capture_then_passthrough(
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body_arg: dict[str, Any],
|
|
stream: bool = False,
|
|
**kwargs: Any,
|
|
) -> httpx.Response:
|
|
# Snapshot what the proxy is about to send.
|
|
captured["method"] = method
|
|
captured["url"] = url
|
|
captured["body"] = body_arg
|
|
captured["original_body_bytes"] = kwargs.get("original_body_bytes")
|
|
captured["body_mutated"] = kwargs.get("body_mutated", True)
|
|
# Then proceed to the real upstream so the response shape is
|
|
# realistic (and we can sanity-check it returned 200).
|
|
return await original_retry(method, url, headers, body_arg, stream=stream, **kwargs)
|
|
|
|
proxy._retry_request = _capture_then_passthrough # type: ignore[assignment]
|
|
try:
|
|
resp = _anthropic_call(proxy_client, api_key=ANTHROPIC_KEY, body=body)
|
|
finally:
|
|
proxy._retry_request = original_retry # type: ignore[assignment]
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
# The captured body must preserve cache_control on the system block.
|
|
captured_body = captured.get("body") or {}
|
|
assert isinstance(captured_body, dict), f"no body captured: {captured!r}"
|
|
sys_blocks = captured_body.get("system")
|
|
assert isinstance(sys_blocks, list) and sys_blocks, (
|
|
f"system blocks dropped from upstream body: {sys_blocks!r}"
|
|
)
|
|
assert sys_blocks[0].get("cache_control") == {"type": "ephemeral"}, (
|
|
f"cache_control mutated: {sys_blocks[0]!r}"
|
|
)
|
|
# And the user content block was preserved as a list-of-content-blocks
|
|
# (not flattened to a string — that would break cache hashing
|
|
# downstream).
|
|
user_msg = captured_body["messages"][0]
|
|
assert isinstance(user_msg["content"], list), f"user content flattened: {user_msg!r}"
|
|
assert user_msg["content"][0]["text"] == "Reply with OK."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4) OpenAI multi-turn through proxy preserves prior assistant turns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not OPENAI_KEY, reason="OPENAI_API_KEY not set")
|
|
def test_openai_chat_completions_multi_turn_through_proxy(
|
|
proxy_client: TestClient,
|
|
) -> None:
|
|
"""A8/B — three-turn conversation through ``/v1/chat/completions`` works,
|
|
and the proxy doesn't drop earlier assistant turns from the messages
|
|
list when forwarding subsequent turns.
|
|
"""
|
|
model = LIVE_CONFIG["openai_model"]
|
|
auth = {
|
|
"authorization": f"Bearer {OPENAI_KEY}",
|
|
"content-type": "application/json",
|
|
}
|
|
|
|
messages: list[dict[str, Any]] = [
|
|
{"role": "system", "content": "Reply with at most three words."},
|
|
{"role": "user", "content": "Name a primary color."},
|
|
]
|
|
|
|
# Turn 1
|
|
resp1 = proxy_client.post(
|
|
"/v1/chat/completions",
|
|
headers=auth,
|
|
json={
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": messages,
|
|
},
|
|
)
|
|
assert resp1.status_code == 200, resp1.text
|
|
assistant1 = resp1.json()["choices"][0]["message"]
|
|
assert assistant1["role"] == "assistant"
|
|
assert assistant1.get("content"), "turn 1 returned empty content"
|
|
messages.append({"role": "assistant", "content": assistant1["content"]})
|
|
|
|
# Turn 2 — referencing turn 1 implicitly forces the model to use prior
|
|
# context. If the proxy drops earlier turns, the answer will be
|
|
# nonsense, but we only assert structure (content non-empty + valid
|
|
# role), not semantic correctness.
|
|
messages.append({"role": "user", "content": "Now name another."})
|
|
resp2 = proxy_client.post(
|
|
"/v1/chat/completions",
|
|
headers=auth,
|
|
json={
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": messages,
|
|
},
|
|
)
|
|
assert resp2.status_code == 200, resp2.text
|
|
assistant2 = resp2.json()["choices"][0]["message"]
|
|
assert assistant2.get("content"), "turn 2 returned empty content"
|
|
messages.append({"role": "assistant", "content": assistant2["content"]})
|
|
|
|
# Turn 3
|
|
messages.append({"role": "user", "content": "And one more."})
|
|
resp3 = proxy_client.post(
|
|
"/v1/chat/completions",
|
|
headers=auth,
|
|
json={
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": messages,
|
|
},
|
|
)
|
|
assert resp3.status_code == 200, resp3.text
|
|
assistant3 = resp3.json()["choices"][0]["message"]
|
|
assert assistant3.get("content"), "turn 3 returned empty content"
|
|
messages.append({"role": "assistant", "content": assistant3["content"]})
|
|
|
|
# Final transcript carries 7 messages (sys + 3 user + 3 assistant).
|
|
# If the proxy were dropping prior assistants from the forwarded
|
|
# messages list (a B-zone bug), turn 3 would still respond — but
|
|
# earlier turns' content would have been silently lost. We assert the
|
|
# client-side list is intact; live-zone compression is not allowed to
|
|
# mutate the agent's view of prior turns.
|
|
assert len(messages) == 7
|
|
assert sum(1 for m in messages if m["role"] == "assistant") == 3
|
|
assert sum(1 for m in messages if m["role"] == "user") == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5) OpenAI streaming SSE chunks arrive in order and form a valid stream
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not OPENAI_KEY, reason="OPENAI_API_KEY not set")
|
|
def test_openai_streaming_sse_chunks_arrive_in_order(
|
|
proxy_client: TestClient,
|
|
) -> None:
|
|
"""A8 — SSE wire-format invariants: each event is ``data: ...\\n\\n``,
|
|
chunks reassemble to non-empty content, terminator is ``data: [DONE]``.
|
|
"""
|
|
model = LIVE_CONFIG["openai_model"]
|
|
body = {
|
|
"model": model,
|
|
"stream": True,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": [{"role": "user", "content": "Count from 1 to 3 inclusive."}],
|
|
}
|
|
|
|
# TestClient.stream returns the full SSE body; we parse it as a
|
|
# newline-delimited stream of `data: ...` events.
|
|
with proxy_client.stream(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
headers={
|
|
"authorization": f"Bearer {OPENAI_KEY}",
|
|
"content-type": "application/json",
|
|
},
|
|
json=body,
|
|
) as resp:
|
|
assert resp.status_code == 200, resp.read().decode("utf-8", errors="replace")
|
|
raw = b"".join(resp.iter_bytes()).decode("utf-8")
|
|
|
|
# Each SSE event ends with a blank line.
|
|
events = [e for e in raw.split("\n\n") if e.strip()]
|
|
assert events, "no SSE events received"
|
|
|
|
data_lines: list[str] = []
|
|
saw_done = False
|
|
for ev in events:
|
|
# Each event is one or more `data: ...` lines (we ignore comments
|
|
# and unknown fields). Multi-line concatenation per the spec.
|
|
for line in ev.splitlines():
|
|
assert line.startswith("data: ") or line.startswith(":"), (
|
|
f"malformed SSE line: {line!r}"
|
|
)
|
|
if line.startswith("data: "):
|
|
payload = line[len("data: ") :]
|
|
if payload.strip() == "[DONE]":
|
|
saw_done = True
|
|
else:
|
|
data_lines.append(payload)
|
|
|
|
assert saw_done, "SSE stream did not terminate with data: [DONE]"
|
|
assert data_lines, "no data chunks received before [DONE]"
|
|
|
|
# Reassemble content from delta.content.
|
|
content_parts: list[str] = []
|
|
for raw_data in data_lines:
|
|
try:
|
|
obj = json.loads(raw_data)
|
|
except json.JSONDecodeError: # pragma: no cover — should never happen
|
|
pytest.fail(f"non-JSON SSE data chunk: {raw_data!r}")
|
|
for choice in obj.get("choices", []):
|
|
delta = choice.get("delta", {}) or {}
|
|
if "content" in delta and delta["content"]:
|
|
content_parts.append(delta["content"])
|
|
full_text = "".join(content_parts)
|
|
assert full_text, "reassembled SSE content is empty"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6) Gemini multi-turn through proxy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not GEMINI_KEY, reason="GEMINI_API_KEY not set")
|
|
def test_gemini_multi_turn_through_proxy(proxy_client: TestClient) -> None:
|
|
"""Gemini handler reachability — two-turn conversation through the
|
|
native ``/v1beta/models/{model}:generateContent`` endpoint.
|
|
"""
|
|
model = LIVE_CONFIG["gemini_model"]
|
|
url = f"/v1beta/models/{model}:generateContent?key={GEMINI_KEY}"
|
|
|
|
contents: list[dict[str, Any]] = [
|
|
{"role": "user", "parts": [{"text": "Reply with the single word HELLO."}]}
|
|
]
|
|
|
|
resp1 = proxy_client.post(url, json={"contents": contents})
|
|
assert resp1.status_code == 200, resp1.text
|
|
data1 = resp1.json()
|
|
text1 = data1["candidates"][0]["content"]["parts"][0]["text"]
|
|
assert text1, "Gemini turn 1 returned empty text"
|
|
|
|
contents.append({"role": "model", "parts": [{"text": text1}]})
|
|
contents.append({"role": "user", "parts": [{"text": "Now reply with the single word WORLD."}]})
|
|
|
|
resp2 = proxy_client.post(url, json={"contents": contents})
|
|
assert resp2.status_code == 200, resp2.text
|
|
data2 = resp2.json()
|
|
text2 = data2["candidates"][0]["content"]["parts"][0]["text"]
|
|
assert text2, "Gemini turn 2 returned empty text"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7) CCR marker round-trip live
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set")
|
|
def test_ccr_marker_round_trip_live() -> None:
|
|
"""B7 — CCR round-trip across the live proxy.
|
|
|
|
Verifies two halves of the B7 contract:
|
|
|
|
(a) **Tool injection** — when ``ccr_inject_tool=True``, the proxy
|
|
injects the ``headroom_retrieve`` tool into the upstream-bound
|
|
``tools`` array on every request. This is the load-bearing claim
|
|
of PR-B7's "always-on tool" hardening: the model can always call
|
|
retrieve, even before any compression has happened.
|
|
|
|
(b) **Retrieve round-trip** — given a CCR hash present in the
|
|
compression store, ``POST /v1/retrieve`` returns the original
|
|
bytes verbatim by hash. We pre-populate the store with a fixture
|
|
entry (matching the established CCR-test pattern in
|
|
``test_proxy_ccr.py``) rather than relying on SmartCrusher's
|
|
internal Rust store, which is not exposed through the Python
|
|
``CompressionStore`` surface served by ``/v1/retrieve``.
|
|
|
|
The combination is the "model calls headroom_retrieve and gets original
|
|
bytes back" story end-to-end.
|
|
"""
|
|
from headroom.cache.compression_store import (
|
|
get_compression_store,
|
|
reset_compression_store,
|
|
)
|
|
|
|
reset_compression_store()
|
|
|
|
# Pre-populate the compression store with a fixture entry. This is
|
|
# the same pattern existing CCR tests use to drive the retrieve
|
|
# surface — the round-trip path is what we're guarding here, not the
|
|
# SmartCrusher write path (which is exercised by other tests).
|
|
fixture_items = [
|
|
{
|
|
"id": i,
|
|
"name": f"row-{i}",
|
|
"category": ["alpha", "beta", "gamma"][i % 3],
|
|
"score": float(i) * 0.07,
|
|
"metadata": {"source": "ccr-fixture", "rev": 2},
|
|
}
|
|
for i in range(LIVE_CONFIG["ccr_tool_result_items"])
|
|
]
|
|
store = get_compression_store()
|
|
fixture_hash = store.store(
|
|
original=json.dumps(fixture_items),
|
|
compressed=json.dumps(fixture_items[:5]),
|
|
original_tokens=2000,
|
|
compressed_tokens=200,
|
|
original_item_count=len(fixture_items),
|
|
compressed_item_count=5,
|
|
tool_name="fetch_rows",
|
|
)
|
|
assert fixture_hash, "fixture hash not produced"
|
|
|
|
# Build a CCR-enabled proxy and capture upstream-bound body to check
|
|
# that headroom_retrieve was injected.
|
|
ccr_config = ProxyConfig(
|
|
optimize=True,
|
|
ccr_inject_tool=True,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
)
|
|
ccr_app = create_app(ccr_config)
|
|
with TestClient(ccr_app) as ccr_proxy_client:
|
|
proxy = ccr_proxy_client.app.state.proxy
|
|
original_retry = proxy._retry_request
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def _capture(
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body_arg: dict[str, Any],
|
|
stream: bool = False,
|
|
**kwargs: Any,
|
|
) -> httpx.Response:
|
|
captured["body"] = body_arg
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "msg_x",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": body_arg.get("model", ""),
|
|
"content": [{"type": "text", "text": "ok"}],
|
|
"stop_reason": "end_turn",
|
|
"usage": {"input_tokens": 1, "output_tokens": 1},
|
|
},
|
|
)
|
|
|
|
proxy._retry_request = _capture # type: ignore[assignment]
|
|
try:
|
|
# Body carries:
|
|
# * a user-defined tool — ensures the proxy MERGES
|
|
# headroom_retrieve with existing tools rather than only
|
|
# injecting on empty-tools requests.
|
|
# * a CCR compression marker on the prior tool_result —
|
|
# this triggers ``has_compressed_content_this_turn=True``
|
|
# in the session-sticky tool injector (PR-B7), which
|
|
# forces ``headroom_retrieve`` into the tools array on
|
|
# this very turn (no need for a sticky-on second turn).
|
|
tool_use_id = "toolu_ccr_fixture_001"
|
|
ccr_marker = (
|
|
f"[{LIVE_CONFIG['ccr_tool_result_items']} items compressed "
|
|
f"to 5. Retrieve more: hash={fixture_hash}]"
|
|
)
|
|
resp = _anthropic_call(
|
|
ccr_proxy_client,
|
|
api_key=ANTHROPIC_KEY,
|
|
body={
|
|
"model": LIVE_CONFIG["anthropic_model_fallback"],
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"tools": [
|
|
{
|
|
"name": "fetch_rows",
|
|
"description": "Fetch rows.",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
],
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Run fetch_rows."},
|
|
],
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": tool_use_id,
|
|
"name": "fetch_rows",
|
|
"input": {},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": tool_use_id,
|
|
"content": ccr_marker,
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": "Reply OK.",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
)
|
|
finally:
|
|
proxy._retry_request = original_retry # type: ignore[assignment]
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
# (a) headroom_retrieve injected.
|
|
forwarded = captured.get("body") or {}
|
|
forwarded_tools = forwarded.get("tools") or []
|
|
tool_names = {t.get("name") for t in forwarded_tools if isinstance(t, dict)}
|
|
assert "headroom_retrieve" in tool_names, (
|
|
f"headroom_retrieve tool not injected; forwarded tools={tool_names}"
|
|
)
|
|
# User's own tool must survive (additive injection, not replacement).
|
|
assert "fetch_rows" in tool_names, (
|
|
f"user tool dropped after CCR injection; tools={tool_names}"
|
|
)
|
|
|
|
# (b) /v1/retrieve round-trip returns the original bytes.
|
|
retrieve_resp = ccr_proxy_client.post("/v1/retrieve", json={"hash": fixture_hash})
|
|
assert retrieve_resp.status_code == 200, retrieve_resp.text
|
|
retrieved = retrieve_resp.json()
|
|
assert retrieved["hash"] == fixture_hash
|
|
assert retrieved.get("original_content"), "retrieve returned empty content"
|
|
parsed = retrieved["original_content"]
|
|
if isinstance(parsed, str):
|
|
parsed = json.loads(parsed)
|
|
assert isinstance(parsed, list)
|
|
assert len(parsed) == LIVE_CONFIG["ccr_tool_result_items"], (
|
|
f"retrieved {len(parsed)} rows, expected {LIVE_CONFIG['ccr_tool_result_items']}"
|
|
)
|
|
assert parsed[0]["name"] == "row-0"
|
|
|
|
reset_compression_store()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8) Memory tail injection lives in user-tail (system prompt untouched)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.skipif(not ANTHROPIC_KEY, reason="ANTHROPIC_API_KEY not set")
|
|
def test_memory_tail_injection_does_not_modify_system_prompt_live(
|
|
proxy_client: TestClient,
|
|
) -> None:
|
|
"""B6/A2 — with ``MemoryMode.AUTO_TAIL`` and ``inject_context=True``,
|
|
retrieved memory must be appended to the latest user message, NOT the
|
|
system prompt.
|
|
|
|
Build a dedicated proxy instance with memory enabled (the module-scope
|
|
client has memory off). Pre-seed the LocalBackend so the live request
|
|
has something to retrieve. Capture the upstream-bound body via a
|
|
``_retry_request`` wrapper and assert positioning.
|
|
"""
|
|
from headroom.memory.backends.local import LocalBackend, LocalBackendConfig
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db_path = str(Path(tmp) / "memory.db")
|
|
|
|
# Build proxy with memory ON.
|
|
config = ProxyConfig(
|
|
optimize=False, # keep the body simple for positional asserts
|
|
ccr_inject_tool=False,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
memory_enabled=True,
|
|
memory_backend="local",
|
|
memory_db_path=db_path,
|
|
memory_inject_tools=False,
|
|
memory_inject_context=True,
|
|
memory_mode="auto_tail",
|
|
memory_top_k=3,
|
|
memory_min_similarity=0.0,
|
|
)
|
|
app = create_app(config)
|
|
with TestClient(app) as memory_client:
|
|
proxy = memory_client.app.state.proxy
|
|
model = _resolve_anthropic_model(memory_client, ANTHROPIC_KEY)
|
|
|
|
# Pre-seed the backend with a memory the test's user query is
|
|
# likely to retrieve. We initialize the backend out-of-band so
|
|
# the seed is in place before the request fires.
|
|
import asyncio as _asyncio
|
|
|
|
async def _seed_and_close() -> None:
|
|
seed_backend = LocalBackend(LocalBackendConfig(db_path=db_path))
|
|
try:
|
|
await seed_backend.save_memory(
|
|
content=LIVE_CONFIG["memory_seed_text"],
|
|
user_id=LIVE_CONFIG["memory_user_id"],
|
|
)
|
|
finally:
|
|
close = getattr(seed_backend, "close", None)
|
|
if close is not None:
|
|
res = close()
|
|
if hasattr(res, "__await__"):
|
|
await res
|
|
|
|
_asyncio.run(_seed_and_close())
|
|
|
|
# Capture upstream body.
|
|
original_retry = proxy._retry_request
|
|
captured: dict[str, Any] = {}
|
|
|
|
async def _capture(
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body_arg: dict[str, Any],
|
|
stream: bool = False,
|
|
**kwargs: Any,
|
|
) -> httpx.Response:
|
|
captured["body"] = body_arg
|
|
# Return a synthetic 200 — no need to hit upstream for an
|
|
# injection-positioning assertion.
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "msg_synthetic",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": model,
|
|
"content": [{"type": "text", "text": "ok"}],
|
|
"stop_reason": "end_turn",
|
|
"usage": {
|
|
"input_tokens": 0,
|
|
"output_tokens": 1,
|
|
},
|
|
},
|
|
)
|
|
|
|
proxy._retry_request = _capture # type: ignore[assignment]
|
|
try:
|
|
input_system = "You are a careful, concise assistant."
|
|
input_user = "Remind me what color I prefer and what indentation I use."
|
|
resp = _anthropic_call(
|
|
memory_client,
|
|
api_key=ANTHROPIC_KEY,
|
|
body={
|
|
"model": model,
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"system": input_system,
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Earlier turn."},
|
|
],
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": "Acknowledged.",
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": input_user,
|
|
},
|
|
],
|
|
},
|
|
extra_headers={
|
|
"x-headroom-user-id": LIVE_CONFIG["memory_user_id"],
|
|
},
|
|
)
|
|
finally:
|
|
proxy._retry_request = original_retry # type: ignore[assignment]
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
forwarded = captured.get("body") or {}
|
|
# (a) system prompt byte-identical to input.
|
|
assert forwarded.get("system") == input_system, (
|
|
"memory injection mutated the system prompt; "
|
|
f"forwarded system={forwarded.get('system')!r}"
|
|
)
|
|
# (b) latest user message tail contains the memory text.
|
|
messages = forwarded.get("messages", [])
|
|
assert messages, "no messages forwarded"
|
|
latest = messages[-1]
|
|
assert latest.get("role") == "user"
|
|
tail_text = json.dumps(latest)
|
|
# Memory injection in AUTO_TAIL mode appends the
|
|
# "Relevant Memories" preamble and at least one of the seeded
|
|
# phrases. We assert presence of the seed substring only.
|
|
assert "tabs over spaces" in tail_text or "favorite color" in tail_text, (
|
|
f"memory text not found on latest user tail; latest={latest!r}"
|
|
)
|
|
# (c) earlier messages untouched.
|
|
assert messages[0]["role"] == "user"
|
|
assert messages[0]["content"] == [{"type": "text", "text": "Earlier turn."}]
|
|
assert messages[1]["role"] == "assistant"
|
|
assert messages[1]["content"] == "Acknowledged."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 9) Auth-mode classification routes payg vs oauth correctly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _classify_auth_mode_from_headers(headers: dict[str, str]) -> str:
|
|
"""Headroom's load-bearing auth-mode classifier (Phase F-prep).
|
|
|
|
Mirrors the classifications spec'd in
|
|
``project_auth_mode_compression_nuances.md`` and the TOIN aggregation
|
|
key (`auth_mode ∈ {"unknown","payg","oauth","subscription"}`).
|
|
|
|
Until Phase F lands a formal classifier in
|
|
``headroom/proxy/auth_mode.py``, this is the canonical ruleset:
|
|
* ``x-api-key`` header set -> "payg"
|
|
* ``Bearer sk-ant-api...`` -> "payg"
|
|
* ``Bearer sk-ant-oat...`` -> "oauth"
|
|
* other -> "unknown"
|
|
"""
|
|
lowered = {k.lower(): v for k, v in headers.items()}
|
|
if lowered.get("x-api-key"):
|
|
return "payg"
|
|
auth = lowered.get("authorization", "")
|
|
if auth.startswith("Bearer sk-ant-api"):
|
|
return "payg"
|
|
if auth.startswith("Bearer sk-ant-oat"):
|
|
return "oauth"
|
|
return "unknown"
|
|
|
|
|
|
def test_classify_auth_mode_routes_payg_vs_oauth(proxy_client: TestClient) -> None:
|
|
"""Phase F-prep / B5 — no network calls. Three header shapes are sent
|
|
to the proxy; we capture what the dispatcher saw and classify each.
|
|
|
|
NB: This is the only test in the file that does NOT make a live API
|
|
call. It uses real header shapes (``x-api-key``, two Bearer prefixes)
|
|
to verify the canonical classifier ruleset matches the contract the
|
|
Phase F surface will codify.
|
|
"""
|
|
proxy = proxy_client.app.state.proxy
|
|
original_retry = proxy._retry_request
|
|
captured: list[dict[str, Any]] = []
|
|
|
|
async def _capture_short_circuit(
|
|
method: str,
|
|
url: str,
|
|
headers: dict[str, str],
|
|
body_arg: dict[str, Any],
|
|
stream: bool = False,
|
|
**kwargs: Any,
|
|
) -> httpx.Response:
|
|
captured.append(dict(headers))
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "msg_x",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": body_arg.get("model", ""),
|
|
"content": [{"type": "text", "text": "ok"}],
|
|
"stop_reason": "end_turn",
|
|
"usage": {"input_tokens": 1, "output_tokens": 1},
|
|
},
|
|
)
|
|
|
|
proxy._retry_request = _capture_short_circuit # type: ignore[assignment]
|
|
try:
|
|
common_body = {
|
|
"model": LIVE_CONFIG["anthropic_model_fallback"],
|
|
"max_tokens": LIVE_CONFIG["max_tokens_short"],
|
|
"messages": [{"role": "user", "content": "ping"}],
|
|
}
|
|
|
|
# 1) PAYG via x-api-key
|
|
# ggignore: synthetic auth-mode classifier fixture (no live API call).
|
|
proxy_client.post(
|
|
"/v1/messages",
|
|
headers={
|
|
"x-api-key": "sk-ant-api03-payg-fixture", # noqa: S105
|
|
"anthropic-version": LIVE_CONFIG["anthropic_version"],
|
|
"content-type": "application/json",
|
|
},
|
|
json=common_body,
|
|
)
|
|
# 2) OAuth via sk-ant-oat01- bearer
|
|
# ggignore: synthetic auth-mode classifier fixture (no live API call).
|
|
proxy_client.post(
|
|
"/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer sk-ant-oat01-oauth-fixture", # noqa: S105
|
|
"anthropic-version": LIVE_CONFIG["anthropic_version"],
|
|
"content-type": "application/json",
|
|
},
|
|
json=common_body,
|
|
)
|
|
# 3) PAYG via sk-ant-api03- bearer (some clients send it this way)
|
|
# ggignore: synthetic auth-mode classifier fixture (no live API call).
|
|
proxy_client.post(
|
|
"/v1/messages",
|
|
headers={
|
|
"authorization": "Bearer sk-ant-api03-payg-bearer-fixture", # noqa: S105
|
|
"anthropic-version": LIVE_CONFIG["anthropic_version"],
|
|
"content-type": "application/json",
|
|
},
|
|
json=common_body,
|
|
)
|
|
finally:
|
|
proxy._retry_request = original_retry # type: ignore[assignment]
|
|
|
|
assert len(captured) == 3, f"expected 3 dispatcher calls, captured {len(captured)}"
|
|
|
|
expected = ["payg", "oauth", "payg"]
|
|
actual = [_classify_auth_mode_from_headers(h) for h in captured]
|
|
assert actual == expected, (
|
|
f"auth-mode classification mismatch:\n"
|
|
f" expected={expected}\n"
|
|
f" actual ={actual}\n"
|
|
f" captured headers (auth-bearing keys only)=\n"
|
|
+ "\n".join(
|
|
" "
|
|
+ json.dumps(
|
|
{
|
|
k: ("<set>" if v else "")
|
|
for k, v in h.items()
|
|
if k.lower() in {"x-api-key", "authorization"}
|
|
}
|
|
)
|
|
for h in captured
|
|
)
|
|
)
|