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.
|
# Nur für Reasoning-Modelle (gpt-5*, o*): minimal|low|medium|high. Für andere Modelle leer lassen.
|
||||||
OPENAI_REASONING_EFFORT=low
|
OPENAI_REASONING_EFFORT=low
|
||||||
OPENAI_TIMEOUT=90
|
OPENAI_TIMEOUT=90
|
||||||
|
# Optional: OpenAI-kompatibler Endpunkt/Proxy, leer = api.openai.com
|
||||||
|
OPENAI_BASE_URL=
|
||||||
# Optionale Kostenbremse: KI-Aufrufe pro Tag (0 = unbegrenzt)
|
# Optionale Kostenbremse: KI-Aufrufe pro Tag (0 = unbegrenzt)
|
||||||
AI_RATE_LIMIT_PER_USER_PER_DAY=0
|
AI_RATE_LIMIT_PER_USER_PER_DAY=0
|
||||||
AI_RATE_LIMIT_GLOBAL_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 |
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | Erster Admin (nur beim ersten Start) | leer |
|
||||||
| `OPENAI_API_KEY` | Schaltet die KI-Funktionen frei | leer = KI aus |
|
| `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_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` |
|
| `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_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` |
|
| `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.
|
# For reasoning models (gpt-5*, o*): minimal|low|medium|high. Leave empty for other models.
|
||||||
OPENAI_REASONING_EFFORT = env("OPENAI_REASONING_EFFORT", "low")
|
OPENAI_REASONING_EFFORT = env("OPENAI_REASONING_EFFORT", "low")
|
||||||
OPENAI_TIMEOUT = env_int("OPENAI_TIMEOUT", 90)
|
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.
|
# 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_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)
|
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_API_KEY = ""
|
||||||
OPENAI_MODEL = "gpt-5-mini"
|
OPENAI_MODEL = "gpt-5-mini"
|
||||||
OPENAI_REASONING_EFFORT = "low"
|
OPENAI_REASONING_EFFORT = "low"
|
||||||
|
OPENAI_BASE_URL = ""
|
||||||
AI_RATE_LIMIT_PER_USER_PER_DAY = 0
|
AI_RATE_LIMIT_PER_USER_PER_DAY = 0
|
||||||
AI_RATE_LIMIT_GLOBAL_PER_DAY = 0
|
AI_RATE_LIMIT_GLOBAL_PER_DAY = 0
|
||||||
COMMENT_RATE_LIMIT_PER_HOUR = 30
|
COMMENT_RATE_LIMIT_PER_HOUR = 30
|
||||||
|
|||||||
+2
-1
@@ -206,7 +206,8 @@ def _check_limits(user):
|
|||||||
|
|
||||||
|
|
||||||
def _client():
|
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):
|
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"))
|
monkeypatch.setattr(ai, "_client", lambda: StubClient(content='{"is_re', finish_reason="length"))
|
||||||
with pytest.raises(ai.AIError, match="zu lang"):
|
with pytest.raises(ai.AIError, match="zu lang"):
|
||||||
ai._create_completion([])
|
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