From c96b2790bc0db983ff2a8ff513c23971991b606e Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:05 +1000 Subject: [PATCH 1/3] fix: read provider config from Settings in FastAPI router The dev-path Router resolved API keys and base URLs from os.environ directly, so a .env file alone (the documented setup) produced a misleading "Unknown model" 400 instead of configuring the provider. Delegate to the shared bridge resolvers (mojo_bridge.routing) so both servers resolve routes, keys, and base URLs identically via Settings, and split the error paths: 400 unknown model vs 503 no API key configured. Also adds suite-wide hermetic settings (tests/conftest.py): a local .env leaking into the suite is the same bug class, and 5 existing tests failed whenever one was present. Closes #46 Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- opengateway/main.py | 7 +++- opengateway/router.py | 77 +++++++++++++++--------------------- tests/conftest.py | 42 ++++++++++++++++++++ tests/test_proxy.py | 92 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 168 insertions(+), 50 deletions(-) create mode 100644 tests/conftest.py diff --git a/opengateway/main.py b/opengateway/main.py index bc8e6c5..255a30d 100644 --- a/opengateway/main.py +++ b/opengateway/main.py @@ -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): @@ -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}") diff --git a/opengateway/router.py b/opengateway/router.py index 873e3a8..2848e2b 100644 --- a/opengateway/router.py +++ b/opengateway/router.py @@ -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(): diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4d64a5b --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 8c2e631..d21eda7 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -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) @@ -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() From 4ef651f61fabe399b35583bf9334731abd0b5db7 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:23 +1000 Subject: [PATCH 2/3] docs: add mock-upstream demo section to README Show how to run the full gateway path offline with the bundled tests/mock_upstream.py instead of a paid API key. Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 69f0471..9924a2e 100644 --- a/README.md +++ b/README.md @@ -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 From d25be18591c3b6f523b8a50b41354dcefbdaa28a Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:30:24 +1000 Subject: [PATCH 3/3] chore: gitignore .playwright-mcp Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fc0dedb..805e9e6 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,7 @@ dist-mojo/ # Personal research notes (not shipped with the project) .tmp/ +# Browser MCP screenshots/logs +.playwright-mcp/ + uv.lock