Multi-stage evidence-grounded inference architecture replacing the monolithic 9B model extraction pipeline. CPU-first specialist services handle routine extraction while the 9B vLLM model is preserved for semantic adjudication of ambiguous cases. Key components: - Capability-aware inference gateway (OpenAI-compatible + Ollama) - Endpoint registry with DB migrations and REST API - Sentence-aware document segmenter (property tests) - Deterministic financial parsing with offset integrity - Symbol resolution with ambiguity detection - Specialist service (GLiNER2, dynamic batching, K8s deployment) - Company-specific sentiment (FinBERT, calibration) - Retrieval-based novelty and duplicate detection - Confidence calibration pipeline - Deterministic routing engine (property tests) - 9B adjudication layer with VRAM gating - Stock-specific impact model (features, labels, baseline, trained) - Pipeline orchestrator (state machine, queues, leases, feature flags) - Bounded parallelism (async workers, semaphore, load shedding) - Observability (tracing, metrics, alerts) - Compatibility adapter (v3→v2 golden mapping tests) - Shadow/canary promotion framework - Active learning and fine-tuning pipeline Test results: 1,161 tests pass, ruff lint clean. All 282 spec tasks completed.
805 lines
26 KiB
Python
805 lines
26 KiB
Python
"""Contract tests for OpenAICompatibleClient.
|
|
|
|
Tests against a mocked compatible server validating all structured-output
|
|
modes, authentication, retries, metadata capture, schema validation, and
|
|
credential safety.
|
|
|
|
Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.7, 2.9
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from services.shared.inference.clients.openai_compatible import (
|
|
OpenAICompatibleClient,
|
|
_redact_headers,
|
|
_resolve_auth_secret,
|
|
)
|
|
from services.shared.inference.models import (
|
|
ChatMessage,
|
|
ErrorCategory,
|
|
InferenceTarget,
|
|
ProviderCapabilities,
|
|
StructuredGenerationRequest,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_target(
|
|
*,
|
|
json_schema: bool = True,
|
|
json_object: bool = False,
|
|
seed: bool = True,
|
|
usage: bool = True,
|
|
auth_secret_ref: str | None = None,
|
|
auth_scheme: str = "bearer",
|
|
extra_headers: dict | None = None,
|
|
extra_body: dict | None = None,
|
|
max_retries: int = 2,
|
|
base_url: str = "http://test-vllm:8000",
|
|
) -> InferenceTarget:
|
|
"""Build a test InferenceTarget."""
|
|
return InferenceTarget(
|
|
endpoint_id=uuid4(),
|
|
deployment_id=uuid4(),
|
|
protocol="openai_chat",
|
|
base_url=base_url,
|
|
model="test-model",
|
|
capabilities=ProviderCapabilities(
|
|
chat_completions=True,
|
|
json_schema=json_schema,
|
|
json_object=json_object,
|
|
seed=seed,
|
|
usage=usage,
|
|
),
|
|
auth_secret_ref=auth_secret_ref,
|
|
auth_scheme=auth_scheme,
|
|
extra_headers=extra_headers or {},
|
|
extra_body=extra_body or {},
|
|
max_retries=max_retries,
|
|
)
|
|
|
|
|
|
def _make_request(
|
|
*,
|
|
schema: dict | None = None,
|
|
temperature: float = 0.0,
|
|
max_tokens: int = 1024,
|
|
) -> StructuredGenerationRequest:
|
|
"""Build a test StructuredGenerationRequest."""
|
|
return StructuredGenerationRequest(
|
|
messages=[
|
|
ChatMessage(role="system", content="You are a helpful assistant."),
|
|
ChatMessage(role="user", content="Extract the data."),
|
|
],
|
|
json_schema=schema,
|
|
max_output_tokens=max_tokens,
|
|
temperature=temperature,
|
|
seed=42,
|
|
timeout_seconds=30.0,
|
|
trace_id="test-trace-001",
|
|
)
|
|
|
|
|
|
_TEST_SCHEMA = {
|
|
"title": "test_response",
|
|
"type": "object",
|
|
"properties": {
|
|
"answer": {"type": "string"},
|
|
"confidence": {"type": "number"},
|
|
},
|
|
"required": ["answer", "confidence"],
|
|
}
|
|
|
|
|
|
def _valid_response_json() -> str:
|
|
return json.dumps({"answer": "AAPL beat earnings", "confidence": 0.95})
|
|
|
|
|
|
def _openai_response(
|
|
content: str,
|
|
status: int = 200,
|
|
*,
|
|
usage: dict | None = None,
|
|
finish_reason: str = "stop",
|
|
request_id: str | None = "req-abc-123",
|
|
) -> httpx.Response:
|
|
"""Build a fake OpenAI-compatible /v1/chat/completions response."""
|
|
body = {
|
|
"id": "chatcmpl-test",
|
|
"object": "chat.completion",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": content},
|
|
"finish_reason": finish_reason,
|
|
}
|
|
],
|
|
}
|
|
if usage:
|
|
body["usage"] = usage
|
|
headers = {}
|
|
if request_id:
|
|
headers["x-request-id"] = request_id
|
|
return httpx.Response(status, json=body, headers=headers)
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.1: Test /v1/chat/completions using httpx.AsyncClient
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_successful_completion_json_schema():
|
|
"""Client sends correct payload and parses json_schema response."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["url"] = str(request.url)
|
|
captured["payload"] = json.loads(request.content)
|
|
captured["headers"] = dict(request.headers)
|
|
return _openai_response(
|
|
_valid_response_json(),
|
|
usage={"prompt_tokens": 50, "completion_tokens": 20},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
request = _make_request(schema=_TEST_SCHEMA)
|
|
result = await client.generate(request)
|
|
|
|
# Verify URL
|
|
assert captured["url"] == "http://test-vllm:8000/v1/chat/completions"
|
|
|
|
# Verify payload structure
|
|
payload = captured["payload"]
|
|
assert payload["model"] == "test-model"
|
|
assert payload["temperature"] == 0.0
|
|
assert payload["max_tokens"] == 1024
|
|
assert payload["seed"] == 42
|
|
assert len(payload["messages"]) == 2
|
|
assert payload["messages"][0]["role"] == "system"
|
|
assert payload["messages"][1]["role"] == "user"
|
|
|
|
# Verify response_format for json_schema mode
|
|
rf = payload["response_format"]
|
|
assert rf["type"] == "json_schema"
|
|
assert rf["json_schema"]["name"] == "test_response"
|
|
assert rf["json_schema"]["strict"] is True
|
|
assert rf["json_schema"]["schema"] == _TEST_SCHEMA
|
|
|
|
# Verify result
|
|
assert result.error is None
|
|
assert result.structured_mode == "json_schema"
|
|
assert result.parsed == {"answer": "AAPL beat earnings", "confidence": 0.95}
|
|
assert result.usage.input_tokens == 50
|
|
assert result.usage.output_tokens == 20
|
|
assert result.request_id == "req-abc-123"
|
|
assert result.finish_reason == "stop"
|
|
assert result.schema_valid is True
|
|
assert result.latency_ms >= 0
|
|
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.2: Test Bearer and configurable authentication headers
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bearer_auth_header(monkeypatch):
|
|
"""Client sends Bearer token from env var."""
|
|
monkeypatch.setenv("VLLM_API_KEY", "secret-token-123")
|
|
captured_headers: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_headers.update(dict(request.headers))
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(auth_secret_ref="VLLM_API_KEY", auth_scheme="bearer")
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert captured_headers["authorization"] == "Bearer secret-token-123"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_auth_header(monkeypatch):
|
|
"""Client sends custom auth header scheme."""
|
|
monkeypatch.setenv("CUSTOM_KEY", "my-api-key-value")
|
|
captured_headers: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_headers.update(dict(request.headers))
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(
|
|
auth_secret_ref="CUSTOM_KEY", auth_scheme="X-API-Key"
|
|
)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert captured_headers["x-api-key"] == "my-api-key-value"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_auth_when_secret_ref_is_none():
|
|
"""No Authorization header when auth_secret_ref is None."""
|
|
captured_headers: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_headers.update(dict(request.headers))
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(auth_secret_ref=None)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert "authorization" not in captured_headers
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.3: Test standard response_format.json_schema payloads
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_json_schema_payload_structure():
|
|
"""json_schema mode sends correct response_format structure."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(json_schema=True)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
schema = {
|
|
"title": "extraction",
|
|
"type": "object",
|
|
"properties": {"ticker": {"type": "string"}},
|
|
"required": ["ticker"],
|
|
}
|
|
await client.generate(_make_request(schema=schema))
|
|
|
|
rf = captured["payload"]["response_format"]
|
|
assert rf["type"] == "json_schema"
|
|
assert rf["json_schema"]["name"] == "extraction"
|
|
assert rf["json_schema"]["strict"] is True
|
|
assert rf["json_schema"]["schema"] == schema
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.4: Test configurable vLLM structured_outputs extra-body payloads
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_vllm_extra_body_inclusion():
|
|
"""Extra body fields (vLLM structured_outputs) are included in payload."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
extra_body = {
|
|
"guided_json": {"type": "object", "properties": {"x": {"type": "integer"}}},
|
|
"guided_decoding_backend": "outlines",
|
|
}
|
|
target = _make_target(extra_body=extra_body)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
payload = captured["payload"]
|
|
assert payload["guided_json"] == extra_body["guided_json"]
|
|
assert payload["guided_decoding_backend"] == "outlines"
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.5: Test JSON-object and prompt-only fallback policies
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_json_object_fallback():
|
|
"""Uses json_object mode when target lacks json_schema but has json_object."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(json_schema=False, json_object=True)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert captured["payload"]["response_format"] == {"type": "json_object"}
|
|
assert result.structured_mode == "json_object"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_prompt_only_fallback():
|
|
"""Uses prompt_only mode when target lacks both json_schema and json_object."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(json_schema=False, json_object=False)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
# No response_format in payload
|
|
assert "response_format" not in captured["payload"]
|
|
assert result.structured_mode == "prompt_only"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_schema_no_response_format():
|
|
"""No response_format sent when request has no json_schema."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response("Just a plain response")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(json_schema=True)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=None))
|
|
|
|
assert "response_format" not in captured["payload"]
|
|
assert result.structured_mode == "none"
|
|
assert result.parsed is None
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.6: Test metadata capture (request_id, usage, finish_reason, retries)
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_capture():
|
|
"""Result captures request_id, usage, finish_reason from response."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response(
|
|
_valid_response_json(),
|
|
usage={"prompt_tokens": 120, "completion_tokens": 45},
|
|
finish_reason="length",
|
|
request_id="req-xyz-789",
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.request_id == "req-xyz-789"
|
|
assert result.usage.input_tokens == 120
|
|
assert result.usage.output_tokens == 45
|
|
assert result.finish_reason == "length"
|
|
assert result.retries == 0
|
|
assert result.error is None
|
|
assert result.error_category is None
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.6 continued: Test retry on 429 rate limit
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_on_429_rate_limit():
|
|
"""Client retries on 429 and succeeds on subsequent attempt."""
|
|
call_count = 0
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return httpx.Response(429, text="Rate limited")
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=2)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error is None
|
|
assert result.retries == 1
|
|
assert call_count == 2
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_exhausted_on_500():
|
|
"""Client returns error after exhausting retries on 500."""
|
|
call_count = 0
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return httpx.Response(500, text="Internal Server Error")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=2)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error is not None
|
|
assert result.error_category == ErrorCategory.SERVER_ERROR
|
|
assert result.retries == 2
|
|
assert call_count == 3 # initial + 2 retries
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_on_timeout():
|
|
"""Client retries on timeout and succeeds on next attempt."""
|
|
call_count = 0
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise httpx.ReadTimeout("timed out")
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=2)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error is None
|
|
assert result.retries == 1
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_exhausted():
|
|
"""Client returns timeout error after exhausting retries."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ReadTimeout("timed out")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=1)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error_category == ErrorCategory.TIMEOUT
|
|
assert result.retries == 1
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.7: Test schema validation catches invalid JSON
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_validation_passes_valid_json():
|
|
"""Schema validation marks valid responses as schema_valid=True."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.schema_valid is True
|
|
assert result.parsed is not None
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_validation_catches_invalid_json():
|
|
"""Schema validation marks responses violating the schema as invalid."""
|
|
# Missing required "confidence" field
|
|
invalid_json = json.dumps({"answer": "test"})
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response(invalid_json)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.schema_valid is False
|
|
assert result.parsed == {"answer": "test"} # Still parsed, but marked invalid
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparseable_json_content():
|
|
"""Non-JSON content in structured mode results in schema_valid=False."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response("This is not JSON at all")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.schema_valid is False
|
|
assert result.parsed is None
|
|
await client.close()
|
|
|
|
|
|
# ===========================================================================
|
|
# 11.8: Additional contract tests
|
|
# ===========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authentication_failure_401():
|
|
"""Client returns auth error on 401 without retrying."""
|
|
call_count = 0
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return httpx.Response(401, text="Unauthorized")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=3)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error_category == ErrorCategory.AUTHENTICATION
|
|
assert call_count == 1 # No retries on auth failure
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_choices_error():
|
|
"""Client returns error when response has empty choices."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
body = {"choices": []}
|
|
return httpx.Response(200, json=body)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error is not None
|
|
assert "Empty choices" in result.error
|
|
await client.close()
|
|
|
|
|
|
def test_redact_headers():
|
|
"""Sensitive headers are redacted."""
|
|
headers = {
|
|
"Authorization": "Bearer secret123",
|
|
"X-API-Key": "key456",
|
|
"Content-Type": "application/json",
|
|
}
|
|
redacted = _redact_headers(headers)
|
|
|
|
assert redacted["Authorization"] == "***REDACTED***"
|
|
assert redacted["X-API-Key"] == "***REDACTED***"
|
|
assert redacted["Content-Type"] == "application/json"
|
|
|
|
|
|
def test_resolve_auth_secret_from_env(monkeypatch):
|
|
"""Auth secret resolves from environment variable."""
|
|
monkeypatch.setenv("MY_SECRET", "resolved-value")
|
|
assert _resolve_auth_secret("MY_SECRET") == "resolved-value"
|
|
|
|
|
|
def test_resolve_auth_secret_returns_none_for_missing():
|
|
"""Auth secret returns None when env var is not set."""
|
|
assert _resolve_auth_secret("NONEXISTENT_VAR_12345") is None
|
|
|
|
|
|
def test_resolve_auth_secret_returns_none_for_none_ref():
|
|
"""Auth secret returns None when ref is None."""
|
|
assert _resolve_auth_secret(None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_credentials_not_in_repr():
|
|
"""Client repr does not expose auth secrets."""
|
|
target = _make_target(auth_secret_ref="SECRET_KEY")
|
|
transport = httpx.MockTransport(
|
|
lambda req: _openai_response(_valid_response_json())
|
|
)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
repr_str = repr(client)
|
|
|
|
assert "SECRET_KEY" not in repr_str
|
|
assert "secret" not in repr_str.lower() or "auth_secret" not in repr_str
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connection_error_handling():
|
|
"""Client returns connection error after exhausting retries."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ConnectError("Connection refused")
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(max_retries=1)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.error_category == ErrorCategory.CONNECTION_ERROR
|
|
assert result.retries == 1
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extra_headers_included():
|
|
"""Extra headers from target config are included in request."""
|
|
captured_headers: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured_headers.update(dict(request.headers))
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(extra_headers={"X-Custom-Header": "custom-value"})
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert captured_headers["x-custom-header"] == "custom-value"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_included_when_supported():
|
|
"""Seed is included in payload when target supports it."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(seed=True)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert captured["payload"]["seed"] == 42
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_seed_excluded_when_not_supported():
|
|
"""Seed is excluded from payload when target doesn't support it."""
|
|
captured: dict = {}
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
captured["payload"] = json.loads(request.content)
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target(seed=False)
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert "seed" not in captured["payload"]
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_target_stored_in_result():
|
|
"""InferenceResult includes the target used for the request."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response(_valid_response_json())
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.endpoint_id == target.endpoint_id
|
|
assert result.model == "test-model"
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_request_id_header():
|
|
"""Result has None request_id when server doesn't send x-request-id."""
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return _openai_response(
|
|
_valid_response_json(), request_id=None
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
http = httpx.AsyncClient(transport=transport)
|
|
target = _make_target()
|
|
client = OpenAICompatibleClient(target, http_client=http)
|
|
|
|
result = await client.generate(_make_request(schema=_TEST_SCHEMA))
|
|
|
|
assert result.request_id is None
|
|
await client.close()
|