Files
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

431 lines
14 KiB
Python

from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from free_claude_code.core.failures import ExecutionFailure, FailureKind
from free_claude_code.providers.nvidia_nim import NvidiaNimProvider
from tests.api.support import create_test_app
app = create_test_app()
# Mock provider
mock_provider = MagicMock(spec=NvidiaNimProvider)
# Track stream_response calls for test_model_mapping
_stream_response_calls: list = []
async def _mock_stream_response(*args, **kwargs):
"""Minimal async generator for streaming tests."""
_stream_response_calls.append((args, kwargs))
yield "event: message_start\ndata: {}\n\n"
yield "[DONE]\n\n"
async def _mock_pre_start_rate_limit(*args, **kwargs):
"""Provider stream that fails before any downstream-visible SSE chunk."""
_stream_response_calls.append((args, kwargs))
raise ExecutionFailure(
kind=FailureKind.RATE_LIMIT,
status_code=429,
message="upstream is busy",
retryable=True,
)
yield "unreachable"
async def _mock_empty_stream(*args, **kwargs):
"""Provider stream that completes without a protocol frame."""
_stream_response_calls.append((args, kwargs))
if False:
yield "unreachable"
def _terminal_json_error(response, *, status_code: int):
assert response.status_code == status_code
assert response.headers["content-type"].startswith("application/json")
assert response.headers["x-should-retry"] == "false"
request_id = response.headers["request-id"]
payload = response.json()
assert payload["request_id"] == request_id
return payload["error"]
mock_provider.stream_response = _mock_stream_response
@pytest.fixture(scope="module")
def client():
"""HTTP client with provider resolution stubbed; patch only for this file."""
with (
patch(
"free_claude_code.api.routes.resolve_provider",
return_value=mock_provider,
),
TestClient(app) as test_client,
):
yield test_client
def test_root(client: TestClient):
response = client.get("/")
assert response.status_code == 200
assert response.json()["status"] == "ok"
assert response.headers["request-id"].startswith("req_")
def test_health(client: TestClient):
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
assert response.headers["request-id"].startswith("req_")
def test_models_list(client: TestClient):
response = client.get("/v1/models")
assert response.status_code == 200
data = response.json()
assert data["has_more"] is False
ids = [item["id"] for item in data["data"]]
assert "claude-sonnet-4-20250514" in ids
assert data["first_id"] == ids[0]
assert data["last_id"] == ids[-1]
assert response.headers["x-request-id"] == response.headers["request-id"]
def test_probe_endpoints_return_204_with_allow_headers(client: TestClient):
responses = [
client.head("/"),
client.options("/"),
client.head("/health"),
client.options("/health"),
client.head("/v1/messages"),
client.options("/v1/messages"),
client.head("/v1/messages/count_tokens"),
client.options("/v1/messages/count_tokens"),
]
for response in responses:
assert response.status_code == 204
assert "Allow" in response.headers
def test_create_message_stream(client: TestClient):
"""Create message returns streaming response."""
_stream_response_calls.clear()
payload = {
"model": "claude-3-sonnet",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 100,
"stream": True,
}
response = client.post("/v1/messages", json=payload)
assert response.status_code == 200
assert "text/event-stream" in response.headers.get("content-type", "")
content = b"".join(response.iter_bytes())
assert b"message_start" in content or b"event:" in content
assert _stream_response_calls[0][1]["request_id"] == response.headers["request-id"]
def test_create_message_ingress_error_has_request_id_without_terminal_header(
client: TestClient,
):
response = client.post(
"/v1/messages",
json={"model": "test", "messages": [], "max_tokens": 10, "stream": True},
)
assert response.status_code == 400
assert "x-should-retry" not in response.headers
assert response.json()["request_id"] == response.headers["request-id"]
def test_create_message_schema_validation_has_request_id_without_terminal_header(
client: TestClient,
):
response = client.post(
"/v1/messages",
json={"model": "test", "messages": "not-a-list"},
)
assert response.status_code == 422
assert response.headers["request-id"].startswith("req_")
assert "x-should-retry" not in response.headers
def test_create_message_pre_start_provider_error_returns_terminal_json(
client: TestClient,
):
"""Pre-start provider failures keep status without enabling client retries."""
mock_provider.stream_response = _mock_pre_start_rate_limit
_stream_response_calls.clear()
payload = {
"model": "claude-3-sonnet",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 100,
"stream": True,
}
with (
patch("free_claude_code.api.response_streams.trace_event") as trace,
patch("free_claude_code.application.execution.trace_event") as execution_trace,
):
response = client.post("/v1/messages", json=payload)
error = _terminal_json_error(response, status_code=429)
assert error == {"type": "rate_limit_error", "message": "upstream is busy"}
request_id = response.headers["request-id"]
assert _stream_response_calls[0][1]["request_id"] == request_id
route_trace = next(
call.kwargs
for call in execution_trace.call_args_list
if call.kwargs.get("event") == "free_claude_code.api.route.resolved"
)
assert route_trace["request_id"] == request_id
terminal_trace = next(
call.kwargs
for call in trace.call_args_list
if call.kwargs.get("event")
== "free_claude_code.api.response.terminal_execution_error"
)
assert terminal_trace == {
"stage": "egress",
"event": "free_claude_code.api.response.terminal_execution_error",
"source": "api",
"wire_api": "messages",
"request_id": request_id,
"status_code": 429,
"error_type": "rate_limit_error",
"client_should_retry": False,
"exc_type": "ExecutionFailure",
"failure_kind": "rate_limit",
"provider_retryable": True,
}
mock_provider.stream_response = _mock_stream_response
def test_create_message_accepts_system_role_messages(client: TestClient):
"""Create message accepts latest-client system messages."""
mock_provider.stream_response = _mock_stream_response
_stream_response_calls.clear()
payload = {
"model": "claude-3-sonnet",
"messages": [
{"role": "user", "content": "context"},
{"role": "system", "content": "system prompt"},
{"role": "user", "content": "Hi"},
],
"max_tokens": 100,
"stream": True,
}
response = client.post("/v1/messages", json=payload)
assert response.status_code == 200
routed_request = _stream_response_calls[0][0][0]
assert [message.role for message in routed_request.messages] == ["user", "user"]
assert routed_request.system == "system prompt"
def test_model_mapping(client: TestClient):
# Test Haiku mapping
_stream_response_calls.clear()
payload_haiku = {
"model": "claude-3-haiku-20240307",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 100,
"stream": True,
}
client.post("/v1/messages", json=payload_haiku)
assert len(_stream_response_calls) == 1
args = _stream_response_calls[0][0]
kwargs = _stream_response_calls[0][1]
assert args[0].model != "claude-3-haiku-20240307"
assert kwargs["thinking_enabled"] is True
@pytest.mark.parametrize(
("failure", "expected_type"),
[
(
ExecutionFailure(
FailureKind.AUTHENTICATION, 401, "Invalid Key", retryable=False
),
"authentication_error",
),
(
ExecutionFailure(
FailureKind.INVALID_REQUEST,
400,
"Invalid request api_key=SECRET useful detail",
retryable=False,
),
"invalid_request_error",
),
(
ExecutionFailure(
FailureKind.RATE_LIMIT, 429, "Too Many Requests", retryable=True
),
"rate_limit_error",
),
(
ExecutionFailure(
FailureKind.OVERLOADED, 529, "Server Overloaded", retryable=True
),
"overloaded_error",
),
(
ExecutionFailure(
FailureKind.UPSTREAM, 503, "Upstream failed", retryable=True
),
"api_error",
),
],
)
def test_provider_execution_errors_preserve_status_and_type(
client: TestClient,
failure: ExecutionFailure,
expected_type: str,
):
base_payload = {
"model": "test",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10,
"stream": True,
}
def _raise_provider_error(*args, **kwargs):
raise failure
try:
mock_provider.stream_response = _raise_provider_error
response = client.post("/v1/messages", json=base_payload)
error = _terminal_json_error(response, status_code=failure.status_code)
assert error["type"] == expected_type
assert "SECRET" not in error["message"]
if expected_type == "invalid_request_error":
assert "useful detail" in error["message"]
finally:
mock_provider.stream_response = _mock_stream_response
def test_empty_provider_stream_returns_terminal_json(client: TestClient):
mock_provider.stream_response = _mock_empty_stream
try:
response = client.post(
"/v1/messages",
json={
"model": "test",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10,
"stream": True,
},
)
error = _terminal_json_error(response, status_code=500)
assert error["type"] == "api_error"
assert error["message"] == "Stream ended before emitting a response."
finally:
mock_provider.stream_response = _mock_stream_response
def test_generic_stream_exception_returns_terminal_json(client: TestClient):
"""Unexpected provider execution failures return detailed terminal JSON."""
def _raise_runtime(*args, **kwargs):
raise RuntimeError("unexpected crash")
mock_provider.stream_response = _raise_runtime
response = client.post(
"/v1/messages",
json={
"model": "test",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10,
"stream": True,
},
)
error = _terminal_json_error(response, status_code=500)
assert error["type"] == "api_error"
assert error["message"] == "unexpected crash"
mock_provider.stream_response = _mock_stream_response
def test_generic_stream_exception_with_status_code_returns_terminal_json(
client: TestClient,
):
"""Ad-hoc status_code attrs do not become retryable HTTP responses."""
class ExceptionWithStatus(RuntimeError):
def __init__(self, msg: str, status_code: int = 500):
super().__init__(msg)
self.status_code = status_code
def _raise_with_status(*args, **kwargs):
raise ExceptionWithStatus("bad gateway", 502)
mock_provider.stream_response = _raise_with_status
response = client.post(
"/v1/messages",
json={
"model": "test",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10,
"stream": True,
},
)
error = _terminal_json_error(response, status_code=500)
assert error["type"] == "api_error"
assert error["message"] == "bad gateway"
mock_provider.stream_response = _mock_stream_response
def test_generic_stream_exception_empty_message_returns_non_empty_error(
client: TestClient,
):
"""Exceptions with empty __str__ still return a readable HTTP detail."""
class SilentError(RuntimeError):
def __str__(self):
return ""
def _raise_silent(*args, **kwargs):
raise SilentError()
mock_provider.stream_response = _raise_silent
response = client.post(
"/v1/messages",
json={
"model": "test",
"messages": [{"role": "user", "content": "Hi"}],
"max_tokens": 10,
"stream": True,
},
)
error = _terminal_json_error(response, status_code=500)
assert error["type"] == "api_error"
assert error["message"] != ""
mock_provider.stream_response = _mock_stream_response
def test_count_tokens_endpoint(client: TestClient):
"""count_tokens endpoint returns token count."""
response = client.post(
"/v1/messages/count_tokens",
json={"model": "test", "messages": [{"role": "user", "content": "Hello"}]},
)
assert response.status_code == 200
assert "input_tokens" in response.json()
assert response.headers["request-id"].startswith("req_")
def test_stop_endpoint_no_workflow_no_cli_503(client: TestClient):
"""POST /stop without messaging workflow or cli_manager returns 503."""
# Ensure no messaging workflow or cli_manager on app state
if hasattr(app.state, "messaging_workflow"):
delattr(app.state, "messaging_workflow")
if hasattr(app.state, "cli_manager"):
delattr(app.state, "cli_manager")
response = client.post("/stop")
assert response.status_code == 503