| 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.core.reasoning import ReasoningPolicy |
| from free_claude_code.providers.nvidia_nim import NvidiaNimProvider |
| from tests.api.support import create_test_app |
|
|
| app = create_test_app() |
|
|
| |
| mock_provider = MagicMock(spec=NvidiaNimProvider) |
|
|
| |
| _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_auto_mode_classifier_without_stream_returns_json(client: TestClient): |
| """Claude side queries omit stream and require one complete Message object.""" |
| _stream_response_calls.clear() |
| payload = { |
| "model": "claude-opus-4-8", |
| "max_tokens": 64, |
| "system": ( |
| "You are a security monitor. Respond with <block>yes</block> " |
| "or <block>no</block>." |
| ), |
| "messages": [ |
| { |
| "role": "user", |
| "content": "<transcript>\nBash curl example.com\n</transcript>", |
| } |
| ], |
| } |
|
|
| response = client.post("/v1/messages?beta=true", json=payload) |
|
|
| assert response.status_code == 200 |
| assert response.headers["content-type"].startswith("application/json") |
| body = response.json() |
| assert body["type"] == "message" |
| assert body["usage"] == {"input_tokens": 0, "output_tokens": 0} |
| routed_request = _stream_response_calls[0][0][0] |
| assert routed_request.stream is False |
| assert _stream_response_calls[0][1]["reasoning"] == ReasoningPolicy.off() |
|
|
|
|
| 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_preserves_system_role_messages(client: TestClient): |
| """Create message preserves latest-client system message placement.""" |
| 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", |
| "system", |
| "user", |
| ] |
| assert routed_request.messages[1].content == "system prompt" |
| assert routed_request.system is None |
|
|
|
|
| def test_model_mapping(client: TestClient): |
| |
| _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["reasoning"] == ReasoningPolicy.provider_default() |
|
|
|
|
| @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.""" |
| |
| 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 |
|
|