Skip to content
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ dist/
.uv/
*.egg-info/
.env
.vscode/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This project is designed for AI-first development. All agents MUST follow these
- **PROCESS**:
1. If a task involves a new technology/library: **STOP**.
2. Perform a `web_search` or `librarian` task to investigate best practices, common pitfalls, and API usage.
3. Document findings in `docs/skills/<tech-name>.md`.
3. Document findings in `.agents/skills/<tech-name>.md`.
4. Only proceed with implementation once the skill file exists and is reviewed.

### 2. Architecture First
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
# API key for authentication (optional - if not set, no auth required)
api-key: "your-secret-api-key"

# CORS configuration
cors:
allow-origins:
- "http://localhost:8000"

# Models configuration (used by models router)
models:
- id: "gpt-4o"
Expand Down
33 changes: 24 additions & 9 deletions src/llmock/app.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""FastAPI application factory and setup."""

from collections.abc import Callable

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware

Expand All @@ -13,19 +12,22 @@
class APIKeyMiddleware(BaseHTTPMiddleware):
"""Middleware to validate API key for all requests except health."""

def __init__(self, app, config_getter: Callable[[], Config] = get_config):
"""Initialize middleware with config getter."""
def __init__(self, app, config: Config):
"""Initialize middleware with config."""
super().__init__(app)
self.config_getter = config_getter
self.config = config

async def dispatch(self, request: Request, call_next):
"""Check API key before processing request."""
# Skip auth for OPTIONS preflight requests (CORS)
if request.method == "OPTIONS":
return await call_next(request)

# Skip auth for health endpoint
if request.url.path == "/health":
return await call_next(request)

config = self.config_getter()
config_api_key = config.get("api-key")
config_api_key = self.config.get("api-key")

# If no API key configured, allow all requests
if not config_api_key:
Expand All @@ -49,12 +51,25 @@ async def dispatch(self, request: Request, call_next):
return await call_next(request)


def create_app(config_getter: Callable[[], Config] = get_config) -> FastAPI:
def create_app(config: Config = get_config()) -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="llmock")

# Get CORS origins from config
cors_config = config.get("cors", {})
allow_origins = cors_config.get("allow-origins", ["http://localhost:8000"])

# Add CORS middleware to allow browser connections
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

# Add API key middleware
app.add_middleware(APIKeyMiddleware, config_getter=config_getter)
app.add_middleware(APIKeyMiddleware, config=config)

# Include routers
app.include_router(health.router)
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def test_config() -> Config:
@pytest.fixture
async def client(test_config: Config) -> AsyncGenerator[AsyncClient, None]:
"""Provide an async HTTP client for testing."""
app = create_app(config_getter=lambda: test_config)
app = create_app(config=test_config)

# Override the config dependency for testing
app.dependency_overrides[get_config] = lambda: test_config
Expand Down
4 changes: 2 additions & 2 deletions tests/test_api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ async def client_with_auth(
config_with_api_key: Config,
) -> AsyncGenerator[AsyncClient, None]:
"""Client for app with API key configured."""
app = create_app(config_getter=lambda: config_with_api_key)
app = create_app(config=config_with_api_key)
app.dependency_overrides[get_config] = lambda: config_with_api_key

transport = ASGITransport(app=app)
Expand All @@ -41,7 +41,7 @@ async def client_no_auth(
config_without_api_key: Config,
) -> AsyncGenerator[AsyncClient, None]:
"""Client for app without API key configured."""
app = create_app(config_getter=lambda: config_without_api_key)
app = create_app(config=config_without_api_key)
app.dependency_overrides[get_config] = lambda: config_without_api_key

transport = ASGITransport(app=app)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def test_config() -> Config:
@pytest.fixture
async def openai_client(test_config: Config) -> AsyncGenerator[AsyncOpenAI, None]:
"""Provide an AsyncOpenAI client using ASGI transport (no real server needed)."""
app = create_app(config_getter=lambda: test_config)
app = create_app(config=test_config)
app.dependency_overrides[get_config] = lambda: test_config

# Use httpx with ASGITransport to test without spinning up a real server
Expand Down
2 changes: 1 addition & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_config() -> Config:
@pytest.fixture
async def openai_client(test_config: Config) -> AsyncGenerator[AsyncOpenAI, None]:
"""Provide an AsyncOpenAI client using ASGI transport (no real server needed)."""
app = create_app(config_getter=lambda: test_config)
app = create_app(config=test_config)
app.dependency_overrides[get_config] = lambda: test_config

# Use httpx with ASGITransport to test without spinning up a real server
Expand Down
2 changes: 1 addition & 1 deletion tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_config() -> Config:
@pytest.fixture
async def client(test_config: Config) -> AsyncGenerator[httpx.AsyncClient, None]:
"""Provide an async HTTP client for testing."""
app = create_app(config_getter=lambda: test_config)
app = create_app(config=test_config)
app.dependency_overrides[get_config] = lambda: test_config

transport = httpx.ASGITransport(app=app)
Expand Down