diff --git a/.env.example b/.env.example index dd79e8a..3d5766b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 682a5d3..ef0f405 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/config/settings.py b/config/settings.py index 80ea0e4..7879801 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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) diff --git a/config/settings_test.py b/config/settings_test.py index 3f3af35..1590b52 100644 --- a/config/settings_test.py +++ b/config/settings_test.py @@ -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 diff --git a/recipes/ai.py b/recipes/ai.py index 382e3ae..e0fbb15 100644 --- a/recipes/ai.py +++ b/recipes/ai.py @@ -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): diff --git a/tests/test_ai.py b/tests/test_ai.py index f91d26c..85c0fdb 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -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)