Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
337 changes: 337 additions & 0 deletions tests/integration/sdk/anthropic/test_anthropic_messages_llmkatan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "anthropic>=0.40",
# "httpx>=0.27",
# "pytest>=8.0",
# ]
# ///
"""
Anthropic Messages API integration tests against llm-katan.

Starts a Praxis proxy with the Anthropic passthrough pipeline backed
by llm-katan's echo backend, then exercises non-streaming, streaming,
multi-turn, and usage extraction using the official Anthropic Python SDK.

Usage:
cargo build -p praxis-ai-proxy
uv run tests/integration/sdk/anthropic/test_anthropic_messages_llmkatan.py -s -v

Environment variables:
LLM_KATAN_BASE_URL llm-katan base URL (required; skips when unset)
LLM_KATAN_API_KEY llm-katan Anthropic API key (default: llm-katan-anthropic-key)
LLM_KATAN_MODEL model name (default: llm-katan-echo)
PRAXIS_AI_BIN path to praxis-ai binary (auto-detected if unset)
"""

import os
import signal
import socket
import subprocess
import sys
import tempfile
import time
from urllib.parse import urlparse

import pytest
from anthropic import Anthropic

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

LLM_KATAN_BASE_URL = os.environ.get("LLM_KATAN_BASE_URL")
LLM_KATAN_API_KEY = os.environ.get(
"LLM_KATAN_API_KEY", "llm-katan-anthropic-key"
)
LLM_KATAN_MODEL = os.environ.get("LLM_KATAN_MODEL", "llm-katan-echo")
PRAXIS_AI_BIN = os.environ.get("PRAXIS_AI_BIN")

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _free_port() -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] _free_port(), _find_binary(), _wait_for_proxy(), and the subprocess-based praxis_proxy fixture pattern are duplicated verbatim from test_openai_responses_vllm.py. Extract these to a shared module (e.g. tests/integration/sdk/conftest.py or a _helpers.py) so future changes to the proxy lifecycle only need to happen in one place.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, but declining in this PR — extracting shared helpers would modify test_openai_responses_vllm.py which is out of scope. Happy to do it as a follow-up if a maintainer wants it.

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]


def _find_binary() -> str:
if PRAXIS_AI_BIN:
if os.path.isfile(PRAXIS_AI_BIN):
return PRAXIS_AI_BIN
raise FileNotFoundError(
f"PRAXIS_AI_BIN={PRAXIS_AI_BIN!r} not found"
)
for candidate in ["target/debug/praxis-ai", "target/release/praxis-ai"]:
if os.path.isfile(candidate):
return candidate
raise FileNotFoundError(
"praxis-ai binary not found — run `cargo build -p praxis-ai-proxy` first"
)


def _parse_llm_katan_url() -> tuple[str, int, bool]:
"""Parse LLM_KATAN_BASE_URL into (host, port, uses_tls)."""
parsed = urlparse(LLM_KATAN_BASE_URL)
tls = parsed.scheme == "https"
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if tls else 80)
return host, port, tls


def _llm_katan_reachable() -> bool:
if not LLM_KATAN_BASE_URL:
return False
try:
host, port, _ = _parse_llm_katan_url()
with socket.create_connection((host, port), timeout=5):
return True
except OSError:
return False


def _write_config(proxy_port: int) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The OpenAI SDK test loads from examples/configs/openai/responses/full-flow.yaml and patches it, which means config schema changes are automatically caught. This test generates config inline, so a renamed filter or changed config field would silently produce a stale config.

Consider adding an example config under examples/configs/anthropic/ and loading+patching it here, consistent with the OpenAI pattern. If inline generation is intentional (e.g. no suitable example config exists yet), add a comment explaining why.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional — no existing example config matches this pipeline (format + validate + protocol + token_count). Added a comment in the code explaining why inline generation is used. Happy to extract to an example config if a maintainer prefers.

host, port, tls = _parse_llm_katan_url()
tls_block = f"""
tls:
sni: "{host}" """ if tls else ""
# Inline config: no matching example config exists for the
# format+validate+protocol+token_count pipeline used here.
config = f"""\
listeners:
- name: test
address: "127.0.0.1:{proxy_port}"
filter_chains: [anthropic]

filter_chains:
- name: anthropic
filters:
- filter: anthropic_messages_format
on_invalid: continue
- filter: anthropic_validate
- filter: anthropic_messages_protocol
default_version: "2023-06-01"
- filter: token_count
provider: anthropic
- filter: token_usage_headers
- filter: router
routes:
- path_prefix: "/"
cluster: llm-katan
- filter: load_balancer
clusters:
- name: llm-katan
endpoints:
- "{host}:{port}"{tls_block}
"""
fd, path = tempfile.mkstemp(suffix=".yaml")
with os.fdopen(fd, "w") as f:
f.write(config)
return path


def _wait_for_proxy(port: int, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
return
except OSError:
time.sleep(0.2)
raise TimeoutError(f"Praxis did not start within {timeout}s")


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture(scope="session")
def praxis_proxy(tmp_path_factory, request):
"""Start a Praxis proxy backed by llm-katan for the test session."""
if not LLM_KATAN_BASE_URL:
pytest.skip("LLM_KATAN_BASE_URL not set — skipping")
if not _llm_katan_reachable():
pytest.skip(
f"llm-katan not reachable at {LLM_KATAN_BASE_URL} — skipping"
)

port = _free_port()
config_path = _write_config(port)
binary = _find_binary()

log_dir = tmp_path_factory.mktemp("anthropic-logs")
log_path = str(log_dir / "praxis.log")
log_file = open(log_path, "w")
started = False

proc = subprocess.Popen(
[binary, "-c", config_path],
stdout=log_file,
stderr=subprocess.STDOUT,
)
try:
_wait_for_proxy(port)
started = True
yield port
finally:
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
log_file.close()
if not started or request.session.testsfailed > 0:
with open(log_path) as f:
print(
f"\n=== Praxis logs ===\n{f.read()}",
file=sys.stderr,
)
os.unlink(config_path)


@pytest.fixture(scope="session")
def anthropic_client(praxis_proxy):
"""Return an Anthropic client pointed at the local Praxis proxy."""
return Anthropic(
base_url=f"http://127.0.0.1:{praxis_proxy}",
api_key=LLM_KATAN_API_KEY,
max_retries=0,
timeout=180,
)


# ---------------------------------------------------------------------------

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Large] All 6 tests exercise happy paths. The existing Rust integration tests cover error paths (backend_owned_missing_model, backend_owned_empty_messages), but the SDK-level tests do not verify that the anthropic_validate filter rejects invalid requests end-to-end through the real SDK.

Add at least one error-path test, for example:

def test_missing_max_tokens_rejected(self, anthropic_client):
    """Verify the validate filter rejects requests missing max_tokens."""
    with pytest.raises(anthropic.BadRequestError):
        anthropic_client.messages.create(
            model=LLM_KATAN_MODEL,
            messages=[{"role": "user", "content": "test"}],
        )

This confirms the validate filter works correctly when exercised by the real SDK client, not just raw HTTP.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 158f8f5 with test_malformed_json_rejected. Note: the suggested test_missing_max_tokens_rejected would not work here — anthropic_validate only checks proxy-owned envelope requirements (valid JSON, is an object). max_tokens is backend-owned and explicitly deferred to the inference backend per the filter's design. Malformed JSON is the correct error path to test.

# Tests
# ---------------------------------------------------------------------------


class TestAnthropicMessagesLLMKatan:
"""Integration tests for Anthropic Messages API through Praxis."""

def test_non_streaming_basic(self, anthropic_client):
response = anthropic_client.messages.create(
model=LLM_KATAN_MODEL,
max_tokens=128,
messages=[{"role": "user", "content": "Hello from praxis-ai"}],
)

assert response.type == "message"
assert response.role == "assistant"
assert len(response.content) > 0
assert response.content[0].type == "text"
assert len(response.content[0].text) > 0
assert response.stop_reason == "end_turn"

def test_non_streaming_with_system(self, anthropic_client):
response = anthropic_client.messages.create(
model=LLM_KATAN_MODEL,
max_tokens=128,
system="You are a helpful assistant.",
messages=[{"role": "user", "content": "What are you?"}],
)

assert response.type == "message"
assert response.role == "assistant"
assert len(response.content) > 0
assert response.content[0].type == "text"
assert len(response.content[0].text) > 0

def test_streaming_basic(self, anthropic_client):
event_types = set()

with anthropic_client.messages.stream(
model=LLM_KATAN_MODEL,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] event_types = set() discards event ordering. The PR summary claims this test validates the "SSE event lifecycle (message_start → content_block_delta → message_stop)" but the set only confirms presence, not sequence. A proxy bug that emitted message_stop before message_start would still pass.

Collect into a list and add an ordering assertion:

event_types = []

with anthropic_client.messages.stream(
    ...
) as stream:
    for event in stream:
        event_types.append(event.type)

start = event_types.index("message_start")
stop = event_types.index("message_stop")
assert start < stop, (
    f"message_start (pos {start}) should precede "
    f"message_stop (pos {stop}); order: {event_types}"
)
assert any(
    t == "content_block_delta" for t in event_types[start:stop]
), f"content_block_delta should appear between start and stop; got: {event_types}"

max_tokens=128,
messages=[{"role": "user", "content": "Stream test"}],
) as stream:
for event in stream:
event_types.add(event.type)

assert "message_start" in event_types, (
f"should see message_start; saw: {event_types}"
)
assert "content_block_delta" in event_types, (
f"should see content_block_delta; saw: {event_types}"
)
assert "message_stop" in event_types, (
f"should see message_stop; saw: {event_types}"
)

def test_streaming_collects_full_text(self, anthropic_client):
collected = ""

with anthropic_client.messages.stream(
model=LLM_KATAN_MODEL,
max_tokens=128,
messages=[{"role": "user", "content": "Streaming text collection"}],
) as stream:
for text in stream.text_stream:
collected += text

assert len(collected) > 0, "streamed text should not be empty"

def test_multi_turn(self, anthropic_client):
response = anthropic_client.messages.create(
model=LLM_KATAN_MODEL,
max_tokens=128,
messages=[
{"role": "user", "content": "My name is Alice."},
{"role": "assistant", "content": "Hello Alice!"},
{"role": "user", "content": "What is my name?"},
],
)

assert response.type == "message"
assert response.role == "assistant"
assert len(response.content) > 0
assert response.content[0].type == "text"
assert len(response.content[0].text) > 0

def test_usage_present(self, anthropic_client):
response = anthropic_client.messages.create(
model=LLM_KATAN_MODEL,
max_tokens=128,
messages=[{"role": "user", "content": "Usage test"}],
)

assert response.usage is not None, "usage should be present"
assert response.usage.input_tokens > 0, (
f"input_tokens should be > 0; got {response.usage.input_tokens}"
)
assert response.usage.output_tokens > 0, (
f"output_tokens should be > 0; got {response.usage.output_tokens}"
)


def test_malformed_json_rejected(self, anthropic_client, praxis_proxy):
"""Verify anthropic_validate rejects malformed JSON end-to-end."""
import httpx

resp = httpx.post(
f"http://127.0.0.1:{praxis_proxy}/v1/messages",
content=b"not json {{{",
headers={
"content-type": "application/json",
"anthropic-version": "2023-06-01",
"x-api-key": LLM_KATAN_API_KEY,
},
timeout=30,
)

assert resp.status_code == 400, (
f"malformed JSON should be rejected with 400; got {resp.status_code}"
)
body = resp.json()
assert body["error"]["type"] == "invalid_request_error", (
f"error type should be invalid_request_error; got {body}"
)


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"] + sys.argv[1:]))
Loading