diff --git a/src/main.py b/src/main.py index 50ecb14..9559ca0 100644 --- a/src/main.py +++ b/src/main.py @@ -23,6 +23,7 @@ from src.core.config import config from src.core.exceptions import AppException from src.core.router import router as core_router from src.models.router import router as models_router +from src.responses.router import router as responses_router # Configure logging logging.basicConfig( @@ -82,6 +83,7 @@ def create_application() -> FastAPI: application.include_router(core_router) # Health and root endpoints application.include_router(chat_router, prefix=config.API_PREFIX) application.include_router(models_router, prefix=config.API_PREFIX) + application.include_router(responses_router, prefix=config.API_PREFIX) # Responses API return application diff --git a/src/models/service.py b/src/models/service.py index b362d68..e95edfd 100644 --- a/src/models/service.py +++ b/src/models/service.py @@ -1,34 +1,33 @@ """ Models service. -Currently returns mock model list. -TODO: Fetch from Ollama in future. +Returns available models from the model registry. """ -import time +from src.agents.registry import ModelRegistry from src.models.schemas import Model, ModelsResponse -# Mock models (will be replaced with Ollama models later) -MOCK_MODELS = [ - {"id": "mistral-nemo:latest", "created": int(time.time())}, -] - - async def list_models() -> ModelsResponse: """ - List available models (mock implementation). - + List available models from registry. + + Returns models with their capabilities and metadata. + Returns: - Mock list of models + ModelsResponse: List of available models """ + # Get models from registry with capabilities + registry_models = await ModelRegistry.list_models() + + # Convert to Model schema format models = [ Model( id=model["id"], object="model", created=model["created"], - owned_by="system", + owned_by=model["owned_by"], ) - for model in MOCK_MODELS + for model in registry_models ] - + return ModelsResponse(object="list", data=models) diff --git a/tests/conftest.py b/tests/conftest.py index d0c1906..2bdfc03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,7 +37,7 @@ async def async_client() -> AsyncClient: def mock_chat_request() -> dict: """Standard chat completion request fixture.""" return { - "model": "mistral-nemo:latest", + "model": "tatlock", "messages": [ {"role": "user", "content": "Hello, world!"} ], diff --git a/tests/models/test_router.py b/tests/models/test_router.py index c560bbc..48f4a6f 100644 --- a/tests/models/test_router.py +++ b/tests/models/test_router.py @@ -9,23 +9,30 @@ from fastapi.testclient import TestClient def test_list_models(client: TestClient) -> None: """Test listing available models.""" response = client.get("/v1/models") - + assert response.status_code == 200 data = response.json() - + # Verify response structure assert data["object"] == "list" assert "data" in data assert isinstance(data["data"], list) - assert len(data["data"]) > 0 - + + # Should have both models from registry + assert len(data["data"]) == 2 + + # Check for expected model IDs + model_ids = [m["id"] for m in data["data"]] + assert "lorem-tester" in model_ids + assert "tatlock" in model_ids + # Verify model structure - model = data["data"][0] - assert model["object"] == "model" - assert "id" in model - assert model["id"] == "mistral-nemo:latest" - assert "created" in model - assert model["owned_by"] == "system" + for model in data["data"]: + assert model["object"] == "model" + assert "id" in model + assert "created" in model + assert "owned_by" in model + assert model["owned_by"] == "tatlock" @pytest.mark.unit diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..145b509 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,243 @@ +""" +Tests for main application setup and configuration. + +Tests: +- App creation +- CORS configuration +- Lifespan events +- Router registration +- Exception handlers +""" +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.mark.unit +def test_app_creation(): + """Test that application is created correctly.""" + from src.main import app + + assert isinstance(app, FastAPI) + assert app.title == "OpenAI-Compatible API" + assert app.version == "0.1.0" + + +@pytest.mark.unit +def test_app_routers_registered(): + """Test that all routers are registered.""" + from src.main import app + + # Get all registered routes + routes = [route.path for route in app.routes] + + # Core routes + assert "/" in routes + assert "/health" in routes + + # API routes should be present (check prefixes) + api_routes = [r for r in routes if r.startswith("/v1/")] + + # Should have chat completions, responses, and models endpoints + assert any("/v1/chat/completions" in r for r in api_routes) + assert any("/v1/responses" in r for r in api_routes) + assert any("/v1/models" in r for r in api_routes) + + +@pytest.mark.unit +def test_cors_middleware(): + """Test CORS middleware configuration.""" + from src.main import app + + # Check if CORS middleware is registered + # Middleware is wrapped, so we check that at least one middleware exists + assert len(app.user_middleware) > 0 + + # Better check: verify CORS functionality by checking middleware stack + # The middleware should be present (wrapped as Middleware) + middleware_types = [type(m.cls).__name__ if hasattr(m, 'cls') else type(m).__name__ + for m in app.user_middleware] + assert "CORSMiddleware" in middleware_types or len(app.user_middleware) > 0 + + +@pytest.mark.unit +def test_cors_allows_all_origins(client: TestClient): + """Test that CORS allows requests from any origin.""" + # Make OPTIONS request with Origin header + response = client.options( + "/v1/models", + headers={ + "Origin": "http://example.com", + "Access-Control-Request-Method": "GET" + } + ) + + # Should allow CORS + assert response.status_code == 200 + assert "access-control-allow-origin" in response.headers + + +@pytest.mark.unit +def test_cors_allows_credentials(client: TestClient): + """Test that CORS allows credentials.""" + response = client.get( + "/health", + headers={"Origin": "http://example.com"} + ) + + # Should include CORS headers + assert response.status_code == 200 + # Access-Control-Allow-Credentials should be set + # (or Access-Control-Allow-Origin should be present) + assert "access-control-allow-origin" in response.headers + + +@pytest.mark.unit +def test_exception_handlers_registered(): + """Test that custom exception handlers are registered.""" + from src.main import app + from src.core.exceptions import AppException + from fastapi.exceptions import RequestValidationError + + # App should have exception handlers + assert len(app.exception_handlers) > 0 + + # Should handle AppException (parent of ModelNotFoundError) + assert AppException in app.exception_handlers + + # Should handle validation errors + assert RequestValidationError in app.exception_handlers + + +@pytest.mark.unit +def test_model_not_found_exception_handler(client: TestClient): + """Test ModelNotFoundError exception handler.""" + # Try to use non-existent model + response = client.post( + "/v1/responses", + json={ + "model": "non-existent-model-xyz", + "input": [{"role": "user", "content": "test"}], + "stream": False + } + ) + + assert response.status_code == 404 + data = response.json() + assert "detail" in data + + +@pytest.mark.unit +def test_app_exception_handler(client: TestClient): + """Test AppException exception handler.""" + # Trigger validation error (which raises AppException in handler) + response = client.post( + "/v1/responses", + json={ + "model": "lorem-tester", + "input": [], # Empty input might cause issues + "temperature": 3.0 # Invalid temperature + } + ) + + # Should return validation error + assert response.status_code == 422 + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_lifespan_startup(): + """Test lifespan startup event.""" + from src.main import app + from contextlib import asynccontextmanager + + # The lifespan context manager should be defined + assert hasattr(app.router, "lifespan_context") + + # Lifespan should handle startup/shutdown + # We test this by creating the app (which happens in conftest.py) + # If startup fails, the test client wouldn't work + from fastapi.testclient import TestClient + + with TestClient(app) as test_client: + # If we can make a request, startup succeeded + response = test_client.get("/health") + assert response.status_code == 200 + + +@pytest.mark.unit +def test_openapi_schema_generated(): + """Test that OpenAPI schema is generated.""" + from src.main import app + + schema = app.openapi() + + assert schema is not None + assert "openapi" in schema + assert "info" in schema + assert schema["info"]["title"] == "OpenAI-Compatible API" + assert "paths" in schema + + # Should have our endpoints + paths = schema["paths"] + assert "/v1/chat/completions" in paths + assert "/v1/responses" in paths + assert "/v1/models" in paths + + +@pytest.mark.unit +def test_openapi_docs_accessible(client: TestClient): + """Test that OpenAPI docs are accessible.""" + # Swagger UI + response = client.get("/docs") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + # ReDoc + response = client.get("/redoc") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +@pytest.mark.unit +def test_app_metadata(): + """Test application metadata.""" + from src.main import app + + assert app.title == "OpenAI-Compatible API" + assert app.version == "0.1.0" + # Description is not set in main.py, so it will be empty + # We just verify the important metadata is present + assert app.debug is not None # Debug flag should be set + + +@pytest.mark.unit +def test_app_contact_info(): + """Test that app has contact information.""" + from src.main import app + + # OpenAPI schema should have contact info if configured + schema = app.openapi() + assert "info" in schema + + # Title and version should be set + assert schema["info"]["title"] == "OpenAI-Compatible API" + assert schema["info"]["version"] == "0.1.0" + + +@pytest.mark.unit +def test_validation_error_handler(client: TestClient): + """Test that validation errors are handled properly.""" + # Send invalid request (missing required field) + response = client.post( + "/v1/chat/completions", + json={ + # Missing "model" field + "messages": [{"role": "user", "content": "test"}] + } + ) + + assert response.status_code == 422 + data = response.json() + assert "error" in data + assert data["error"]["type"] == "invalid_request_error"