From 015a0a5821678f9a5f8486f99c6e145f374944ad Mon Sep 17 00:00:00 2001 From: Kyle Dammann Date: Fri, 30 Oct 2026 22:37:39 +0100 Subject: [PATCH] Fix AI calls failing in Docker with empty OPENAI_BASE_URL docker compose passes an empty .env line as OPENAI_BASE_URL="", and with base_url=None the openai SDK fell back to that empty value, so every call failed with "Connection error". Always pass the base URL explicitly. Also disable gunicorn's unused control socket, which logged a permission error because /app is read-only for the app user. Found while deploying to the homelab server. Co-Authored-By: Claude Opus 5.5 --- docker/entrypoint.sh | 6 ++++-- recipes/ai.py | 7 ++++++- tests/test_ai.py | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 8a87b2e..b98a2b4 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,11 +5,13 @@ python manage.py migrate --noinput python manage.py ensure_admin python manage.py purge_ratelimits +# Forwarded headers are evaluated by core.middleware.ProxyMiddleware (TRUSTED_PROXIES), +# not by gunicorn, so gunicorn keeps its default of trusting none of them from remote peers. +# The control socket (gunicorn >= 26) is not used and /app is read-only for the app user. exec gunicorn config.wsgi:application \ --bind 0.0.0.0:8000 \ --workers "${GUNICORN_WORKERS:-2}" \ --threads "${GUNICORN_THREADS:-4}" \ --timeout "${GUNICORN_TIMEOUT:-120}" \ + --no-control-socket \ --access-logfile - -# Forwarded headers are evaluated by core.middleware.ProxyMiddleware (TRUSTED_PROXIES), -# not by gunicorn, so gunicorn keeps its default of trusting none of them from remote peers. diff --git a/recipes/ai.py b/recipes/ai.py index 700cbda..3351911 100644 --- a/recipes/ai.py +++ b/recipes/ai.py @@ -207,9 +207,14 @@ def _check_limits(user): ratelimit.hit(global_key, settings.AI_RATE_LIMIT_GLOBAL_PER_DAY, day) +DEFAULT_BASE_URL = "https://api.openai.com/v1" + + def _client(): + # Always pass base_url explicitly: with base_url=None the SDK falls back to the OPENAI_BASE_URL + # environment variable, which docker compose sets to "" when the .env line is left empty. return openai.OpenAI(api_key=settings.OPENAI_API_KEY, timeout=settings.OPENAI_TIMEOUT, max_retries=1, - base_url=settings.OPENAI_BASE_URL or None) + base_url=settings.OPENAI_BASE_URL or DEFAULT_BASE_URL) def _create_completion(messages): diff --git a/tests/test_ai.py b/tests/test_ai.py index d452843..187b3f6 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -392,3 +392,13 @@ def test_end_to_end_photo_import_through_sdk(stub_openai_server, user_client): 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) + + +def test_empty_base_url_env_falls_back_to_openai(monkeypatch, settings): + """docker compose sets OPENAI_BASE_URL="" for an empty .env line; the SDK must not pick that up.""" + monkeypatch.setenv("OPENAI_BASE_URL", "") + settings.OPENAI_API_KEY = "sk-test" + settings.OPENAI_BASE_URL = "" + assert str(ai._client().base_url).rstrip("/") == "https://api.openai.com/v1" + settings.OPENAI_BASE_URL = "http://proxy.example/v1" + assert str(ai._client().base_url).rstrip("/") == "http://proxy.example/v1"