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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,7 @@ dist-mojo/
# Personal research notes (not shipped with the project)
.tmp/

# Browser MCP screenshots/logs
.playwright-mcp/

uv.lock
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,29 @@ $ curl -s -X POST http://localhost:8080/v1/chat/completions \

---

### Demo without an API key (mock upstream)

No OpenAI key? The repo ships a mock SSE upstream so the full path
(gateway → provider → streaming client) runs offline:

```bash
$ python tests/mock_upstream.py 9100 &
mock upstream on 127.0.0.1:9100
```

Then set these in `.env` instead of a real key:

```bash
OPENAI_API_KEY=sk-mock-local
OPENAI_BASE_URL=http://127.0.0.1:9100/v1
```

Restart the gateway and hit `/v1/chat/completions` as above — the mock
replies `"Hello, streaming world!"`, or four SSE frames when
`"stream": true`. Works with both servers.

---

### TLS termination (in-binary)

Set `TLS_CERT_FILE` and `TLS_KEY_FILE` (PEM-encoded) to terminate TLS
Expand Down
7 changes: 5 additions & 2 deletions opengateway/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from opengateway.auth import AuthService, VirtualKey
from opengateway.config import get_settings
from opengateway.providers.base import ChatRequest
from opengateway.router import Router
from opengateway.router import ProviderNotConfiguredError, Router


class ChatCompletionRequest(BaseModel):
Expand Down Expand Up @@ -70,7 +70,10 @@ async def chat_completions(
if not virtual_key.is_within_budget():
raise HTTPException(status_code=429, detail="Budget exceeded")

provider = router.select_provider(body.model)
try:
provider = router.select_provider(body.model)
except ProviderNotConfiguredError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
if provider is None:
raise HTTPException(status_code=400, detail=f"Unknown model: {body.model}")

Expand Down
77 changes: 32 additions & 45 deletions opengateway/router.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,54 @@
from __future__ import annotations

import os

from opengateway.providers.base import BaseProvider
from opengateway.providers.openai import OpenAIProvider


class ProviderNotConfiguredError(Exception):
"""A route matched the model, but the provider has no API key configured."""

def __init__(self, provider_module: str) -> None:
self.provider_module = provider_module
super().__init__(f"no API key configured for provider {provider_module}")


class Router:
"""Resolve a model string to a ``BaseProvider`` for the FastAPI dev path.

Prefers the explicit ``routes`` table from ``Settings`` when
populated (issue #4); falls back to the prefix-based defaults so
deployments without a configured routing table behave identically
to before.
Delegates to the shared bridge resolvers
(``opengateway.mojo_bridge.routing``) so both servers resolve
models, API keys, and base URLs identically: the ``ROUTES_JSON``
table when populated, the prefix fallback (``gpt-*`` → OpenAI,
``claude-*`` → Anthropic, ...) otherwise. Provider config is read
from ``Settings`` (which loads ``.env`` and the process
environment), not from ``os.environ`` directly.
"""

def __init__(self, registry: dict[str, BaseProvider] | None = None) -> None:
self._providers: dict[str, BaseProvider] = registry or {}

def select_provider(self, model: str) -> BaseProvider | None:
provider_key = self._select_provider_key(model)
if provider_key == "":
"""Return the provider for ``model``, or ``None`` when no route matches.

Raises :class:`ProviderNotConfiguredError` when a route matches
but the provider has no API key configured, so callers can
distinguish "unknown model" from "gateway not configured".
"""
from opengateway.mojo_bridge.chat import _load_provider_class
from opengateway.mojo_bridge.routing import _provider_call_kwargs, resolve_route

_rule, provider_key = resolve_route(model)
if not provider_key:
return None

if provider_key not in self._providers:
api_key = self._api_key_for(provider_key)
if not api_key:
return None
base_url = self._base_url_for(provider_key)
self._providers[provider_key] = OpenAIProvider(api_key=api_key, base_url=base_url)
try:
api_key, base_url = _provider_call_kwargs(provider_key, {})
except RuntimeError as exc:
raise ProviderNotConfiguredError(provider_key) from exc
provider_cls = _load_provider_class(provider_key)
self._providers[provider_key] = provider_cls(api_key=api_key, base_url=base_url)
return self._providers[provider_key]

def _select_provider_key(self, model: str) -> str:
try:
from opengateway.config import get_settings

settings = get_settings()
for rule in settings.routes:
if rule.model == model:
return f"opengateway.providers.{rule.primary}"
except Exception:
pass

if model.startswith("gpt-") or model.startswith("openai/"):
return "opengateway.providers.openai"
return "opengateway.providers.openai"

@staticmethod
def _api_key_for(provider_key: str) -> str:
if provider_key == "opengateway.providers.openai":
return os.environ.get("OPENAI_API_KEY", "")
if provider_key == "opengateway.providers.anthropic":
return os.environ.get("ANTHROPIC_API_KEY", "")
return ""

@staticmethod
def _base_url_for(provider_key: str) -> str | None:
if provider_key == "opengateway.providers.openai":
return os.environ.get("OPENAI_BASE_URL") or None
if provider_key == "opengateway.providers.anthropic":
return os.environ.get("ANTHROPIC_BASE_URL") or None
return None

def reset(self) -> None:
"""Close all provider connections."""
for _provider in self._providers.values():
Expand Down
42 changes: 42 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Suite-wide test hermeticity.

``Settings`` loads ``.env`` from the CWD and reads the process
environment. Left alone, a developer's local ``.env`` (or exported
vars) silently leaks into tests and changes gateway behaviour — the
same class of bug as issue #46. This fixture points the CWD at a
per-test tmp dir and strips gateway env vars so every test starts
from documented defaults and sets exactly what it needs via
``monkeypatch.setenv`` (process env takes precedence over ``.env``
in pydantic-settings).
"""

from __future__ import annotations

from collections.abc import Iterator
from pathlib import Path

import pytest

from opengateway.config import get_settings

_GATEWAY_ENV_VARS = (
"ROOT_KEY",
"REQUIRE_AUTH",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_BASE_URL",
"ANTHROPIC_BASE_URL",
"ROUTES_JSON",
"DATABASE_URL",
"REDIS_URL",
)


@pytest.fixture(autouse=True)
def hermetic_settings(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]:
monkeypatch.chdir(tmp_path)
for var in _GATEWAY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
get_settings.cache_clear()
yield
get_settings.cache_clear()
92 changes: 89 additions & 3 deletions tests/test_proxy.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import pytest
from fastapi.testclient import TestClient

from opengateway.config import get_settings
from opengateway.main import app
from opengateway.router import Router

ROOT = {"Authorization": "Bearer sk-root-change-me"}


@pytest.fixture
def client():
def client() -> TestClient:
return TestClient(app)


Expand Down Expand Up @@ -33,7 +37,89 @@ def test_root_key(self, client):
response = client.post(
"/v1/chat/completions",
json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]},
headers={"Authorization": "Bearer sk-root-change-me"},
headers=ROOT,
)
# 503: the model routes to the OpenAI adapter, but no API key
# is configured in tests (issue #46 made this distinguishable
# from an unknown model).
assert response.status_code == 503


class TestRoutingErrors:
def test_unknown_model(self, client):
response = client.post(
"/v1/chat/completions",
json={
"model": "totally-unknown-model",
"messages": [{"role": "user", "content": "hi"}],
},
headers=ROOT,
)
# 400 because no provider is configured in tests
assert response.status_code == 400
assert response.json()["detail"] == "Unknown model: totally-unknown-model"

def test_missing_api_key_is_not_unknown_model(self, client):
response = client.post(
"/v1/chat/completions",
json={"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}]},
headers=ROOT,
)
assert response.status_code == 503
detail = response.json()["detail"]
assert "no API key configured" in detail
assert "opengateway.providers.openai" in detail


class TestProviderConfigFromSettings:
def test_api_key_read_from_env_file_not_os_environ(self, tmp_path):
"""Regression test for #46: a ``.env`` file alone must configure
the provider — the router must read ``Settings``, not
``os.environ``."""
(tmp_path / ".env").write_text(
"OPENAI_API_KEY=sk-from-env-file\nOPENAI_BASE_URL=http://upstream.test/v1\n"
)
get_settings.cache_clear()
try:
provider = Router().select_provider("gpt-4o-mini")
assert provider is not None
assert provider.api_key == "sk-from-env-file"
assert provider.base_url == "http://upstream.test/v1"
finally:
get_settings.cache_clear()

def test_env_file_only_keys_serve_requests(self, tmp_path, monkeypatch):
"""End-to-end shape of #46: a request succeeds when the key
lives only in ``.env`` (upstream provider stubbed)."""
(tmp_path / ".env").write_text("OPENAI_API_KEY=sk-from-env-file\n")
get_settings.cache_clear()
try:
from opengateway.providers.base import ChatRequest, ChatResponse

class FakeProvider:
def __init__(self, api_key: str, base_url: str | None = None) -> None:
self.api_key = api_key
self.base_url = base_url

async def chat(self, request: ChatRequest) -> ChatResponse:
return ChatResponse(
id="chatcmpl-fake",
model=request.model,
content="hi",
usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
finish_reason="stop",
)

def fake_load(_module: str) -> type:
return FakeProvider

monkeypatch.setattr("opengateway.mojo_bridge.chat._load_provider_class", fake_load)
with TestClient(app) as test_client:
response = test_client.post(
"/v1/chat/completions",
json={"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}]},
headers=ROOT,
)
assert response.status_code == 200
assert response.json()["choices"][0]["message"]["content"] == "hi"
finally:
get_settings.cache_clear()
Loading