Add OPENAI_BASE_URL and end-to-end AI tests through the SDK

- Optional OPENAI_BASE_URL for OpenAI-compatible endpoints or proxies
- Tests run the real openai SDK against a local stub server: request
  shape (model, auth, strict json_schema), text cleanup, photo import
  and an authentication error mapped to the UI message

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kyle Dammann
2026-10-30 22:06:35 +01:00
co-authored by Claude Opus 5.5
parent 74e11845de
commit 3b72436119
6 changed files with 84 additions and 1 deletions
+2
View File
@@ -65,6 +65,8 @@ OPENAI_MODEL=gpt-5-mini
# Nur für Reasoning-Modelle (gpt-5*, o*): minimal|low|medium|high. Für andere Modelle leer lassen.
OPENAI_REASONING_EFFORT=low
OPENAI_TIMEOUT=90
# Optional: OpenAI-kompatibler Endpunkt/Proxy, leer = api.openai.com
OPENAI_BASE_URL=
# Optionale Kostenbremse: KI-Aufrufe pro Tag (0 = unbegrenzt)
AI_RATE_LIMIT_PER_USER_PER_DAY=0
AI_RATE_LIMIT_GLOBAL_PER_DAY=0
+1
View File
@@ -110,6 +110,7 @@ Alle Variablen stehen kommentiert in [`.env.example`](.env.example). Die wichtig
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Erster Admin (nur beim ersten Start) | leer |
| `OPENAI_API_KEY` | Schaltet die KI-Funktionen frei | leer = KI aus |
| `OPENAI_MODEL` | Modell für Text und Bilder (muss Vision + Structured Outputs können) | `gpt-5-mini` |
| `OPENAI_BASE_URL` | Optional: OpenAI-kompatibler Endpunkt oder Proxy | leer |
| `OPENAI_REASONING_EFFORT` | Für Reasoning-Modelle; bei anderen Modellen leer lassen | `low` |
| `AI_RATE_LIMIT_PER_USER_PER_DAY` | KI-Aufrufe pro Nutzer/Tag, `0` = unbegrenzt | `0` |
| `AI_RATE_LIMIT_GLOBAL_PER_DAY` | KI-Aufrufe gesamt/Tag, `0` = unbegrenzt | `0` |
+2
View File
@@ -149,6 +149,8 @@ OPENAI_MODEL = env("OPENAI_MODEL", "gpt-5-mini")
# For reasoning models (gpt-5*, o*): minimal|low|medium|high. Leave empty for other models.
OPENAI_REASONING_EFFORT = env("OPENAI_REASONING_EFFORT", "low")
OPENAI_TIMEOUT = env_int("OPENAI_TIMEOUT", 90)
# Optional: OpenAI-compatible endpoint or proxy (default: api.openai.com)
OPENAI_BASE_URL = env("OPENAI_BASE_URL")
# 0 = unlimited (default). Counted per call, including failed ones.
AI_RATE_LIMIT_PER_USER_PER_DAY = env_int("AI_RATE_LIMIT_PER_USER_PER_DAY", 0)
AI_RATE_LIMIT_GLOBAL_PER_DAY = env_int("AI_RATE_LIMIT_GLOBAL_PER_DAY", 0)
+1
View File
@@ -22,6 +22,7 @@ import ipaddress # noqa: E402
OPENAI_API_KEY = ""
OPENAI_MODEL = "gpt-5-mini"
OPENAI_REASONING_EFFORT = "low"
OPENAI_BASE_URL = ""
AI_RATE_LIMIT_PER_USER_PER_DAY = 0
AI_RATE_LIMIT_GLOBAL_PER_DAY = 0
COMMENT_RATE_LIMIT_PER_HOUR = 30
+2 -1
View File
@@ -206,7 +206,8 @@ def _check_limits(user):
def _client():
return openai.OpenAI(api_key=settings.OPENAI_API_KEY, timeout=settings.OPENAI_TIMEOUT, max_retries=1)
return openai.OpenAI(api_key=settings.OPENAI_API_KEY, timeout=settings.OPENAI_TIMEOUT, max_retries=1,
base_url=settings.OPENAI_BASE_URL or None)
def _create_completion(messages):
+76
View File
@@ -313,3 +313,79 @@ def test_refusal_and_truncation(monkeypatch):
monkeypatch.setattr(ai, "_client", lambda: StubClient(content='{"is_re', finish_reason="length"))
with pytest.raises(ai.AIError, match="zu lang"):
ai._create_completion([])
# --- End to end through the real OpenAI SDK against a local stub server --------------------
@pytest.fixture
def stub_openai_server(settings):
"""A local HTTP server that speaks enough of the Chat Completions API for one request."""
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
received = {}
reply = {"status": 200, "body": None}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
received["path"] = self.path
received["auth"] = self.headers.get("Authorization")
received["body"] = json.loads(self.rfile.read(length))
body = reply["body"] or {
"id": "chatcmpl-test", "object": "chat.completion", "created": 0, "model": received["body"]["model"],
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": ai_answer(), "refusal": None}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
data = json.dumps(body).encode()
self.send_response(reply["status"])
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
settings.OPENAI_API_KEY = "sk-test-local"
settings.OPENAI_BASE_URL = f"http://127.0.0.1:{server.server_port}/v1"
settings.OPENAI_MODEL = "stub-model"
yield received, reply
server.shutdown()
@pytest.mark.django_db
def test_end_to_end_ai_cleanup_through_sdk(stub_openai_server, user_client):
received, _ = stub_openai_server
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
assert response.status_code == 200
assert response.context["form"].initial["title"] == "Linsen-Dal"
assert received["path"] == "/v1/chat/completions"
assert received["auth"] == "Bearer sk-test-local"
body = received["body"]
assert body["model"] == "stub-model"
assert body["response_format"]["json_schema"]["strict"] is True
assert body["messages"][0]["role"] == "system"
assert not Recipe.objects.exists()
@pytest.mark.django_db
def test_end_to_end_api_error_through_sdk(stub_openai_server, user_client):
_, reply = stub_openai_server
reply["status"] = 401
reply["body"] = {"error": {"message": "Incorrect API key", "type": "invalid_request_error", "code": "invalid_api_key"}}
response = user_client.post(reverse("recipes:create"), form_data(ai="1"))
assert "API-Key ist ungültig" in response.content.decode()
@pytest.mark.django_db
def test_end_to_end_photo_import_through_sdk(stub_openai_server, user_client):
received, _ = stub_openai_server
response = user_client.post(reverse("recipes:import_photo"), {"photos": [image_upload()]})
assert response.context["form"].initial["title"] == "Linsen-Dal"
parts = received["body"]["messages"][1]["content"]
assert any(p["type"] == "image_url" and p["image_url"]["url"].startswith("data:image/jpeg;base64,") for p in parts)