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:
co-authored by
Claude Opus 5.5
parent
74e11845de
commit
3b72436119
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user