fix(api): harden Lighthouse provider base URLs (#11928)

Co-authored-by: Josema Camacho <josema@prowler.com>
This commit is contained in:
Adrián Peña
2026-07-10 11:24:40 +02:00
committed by GitHub
parent c93ea035fc
commit 3f2e5929d9
7 changed files with 748 additions and 52 deletions
@@ -0,0 +1 @@
OpenAI-compatible Lighthouse provider base URLs are restricted before connection checks
@@ -0,0 +1,150 @@
import socket
import pytest
from api.validators import validate_lighthouse_openai_compatible_base_url
from django.core.exceptions import ValidationError
def test_lighthouse_base_url_rejects_http_scheme():
with pytest.raises(ValidationError, match="HTTPS"):
validate_lighthouse_openai_compatible_base_url(
"http://openrouter.ai/api/v1",
resolve_dns=False,
)
@pytest.mark.parametrize(
"base_url",
[
"https://openrouter.ai:0/api/v1",
"https://openrouter.ai:-1/api/v1",
"https://openrouter.ai:65536/api/v1",
"https://openrouter.ai:invalid/api/v1",
],
)
def test_lighthouse_base_url_rejects_invalid_port(base_url):
with pytest.raises(ValidationError, match="port is invalid"):
validate_lighthouse_openai_compatible_base_url(
base_url,
resolve_dns=False,
)
@pytest.mark.parametrize("port", [1, 65535])
def test_lighthouse_base_url_accepts_valid_port_boundaries(port):
assert (
validate_lighthouse_openai_compatible_base_url(
f"https://openrouter.ai:{port}/api/v1",
resolve_dns=False,
)
is None
)
def test_lighthouse_base_url_rejects_localhost():
with pytest.raises(ValidationError, match="external public endpoint"):
validate_lighthouse_openai_compatible_base_url(
"https://localhost/v1",
resolve_dns=False,
)
@pytest.mark.parametrize("ip_address", ["10.0.0.1", "172.16.0.1", "192.168.1.1"])
def test_lighthouse_base_url_rejects_private_ip_literal(ip_address):
with pytest.raises(ValidationError, match="external public endpoint"):
validate_lighthouse_openai_compatible_base_url(
f"https://{ip_address}/v1",
resolve_dns=False,
)
def test_lighthouse_base_url_rejects_metadata_ip_literal():
with pytest.raises(ValidationError, match="external public endpoint"):
validate_lighthouse_openai_compatible_base_url(
"https://169.254.169.254/latest/meta-data",
resolve_dns=False,
)
@pytest.mark.parametrize(
"base_url",
[
"https://[::ffff:169.254.169.254]/v1",
"https://[64:ff9b::a9fe:a9fe]/v1",
"https://[2002:a9fe:a9fe::]/v1",
],
)
def test_lighthouse_base_url_rejects_embedded_non_global_ip(base_url):
with pytest.raises(ValidationError, match="external public endpoint"):
validate_lighthouse_openai_compatible_base_url(
base_url,
resolve_dns=False,
)
@pytest.mark.parametrize(
"base_url",
[
"https://[::ffff:93.184.216.34]/v1",
"https://[64:ff9b::5db8:d822]/v1",
"https://[2002:5db8:d822::]/v1",
],
)
def test_lighthouse_base_url_accepts_embedded_public_ip(base_url):
assert (
validate_lighthouse_openai_compatible_base_url(
base_url,
resolve_dns=False,
)
is None
)
def test_lighthouse_base_url_accepts_hostname_without_dns_resolution():
assert (
validate_lighthouse_openai_compatible_base_url(
"https://openrouter.ai/api/v1",
resolve_dns=False,
)
is None
)
def test_lighthouse_base_url_rejects_post_dns_internal_address(monkeypatch):
def resolve_to_metadata(*_args, **_kwargs):
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("169.254.169.254", 443),
)
]
monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_metadata)
with pytest.raises(ValidationError, match="external public endpoint"):
validate_lighthouse_openai_compatible_base_url(
"https://metadata.example.test/v1"
)
def test_lighthouse_base_url_accepts_public_resolved_address(monkeypatch):
def resolve_to_public(*_args, **_kwargs):
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
6,
"",
("93.184.216.34", 443),
)
]
monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_public)
assert (
validate_lighthouse_openai_compatible_base_url("https://openrouter.ai/api/v1")
is None
)
+70
View File
@@ -17308,6 +17308,76 @@ class TestLighthouseProviderConfigViewSet:
error_detail = str(resp.json()).lower()
assert "base_url" in error_detail
@pytest.mark.parametrize(
"base_url",
[
"https://127.0.0.1/v1",
"https://169.254.169.254/latest/meta-data",
],
)
def test_openai_compatible_rejects_internal_base_url_on_create(
self, authenticated_client, base_url
):
payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai_compatible",
"base_url": base_url,
"credentials": {"api_key": "compat-key"},
},
}
}
resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert "base_url" in str(resp.json()).lower()
def test_openai_compatible_rejects_internal_base_url_on_update(
self, authenticated_client
):
create_payload = {
"data": {
"type": "lighthouse-providers",
"attributes": {
"provider_type": "openai_compatible",
"base_url": "https://openrouter.ai/api/v1",
"credentials": {"api_key": "compat-key-123"},
},
}
}
create_resp = authenticated_client.post(
reverse("lighthouse-providers-list"),
data=create_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert create_resp.status_code == status.HTTP_201_CREATED
provider_id = create_resp.json()["data"]["id"]
patch_payload = {
"data": {
"type": "lighthouse-providers",
"id": provider_id,
"attributes": {
"base_url": "https://169.254.169.254/latest/meta-data",
},
}
}
patch_resp = authenticated_client.patch(
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
data=patch_payload,
content_type=API_JSON_CONTENT_TYPE,
)
assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
assert "base_url" in str(patch_resp.json()).lower()
def test_openai_compatible_invalid_credentials(self, authenticated_client):
payload = {
"data": {
+36 -40
View File
@@ -57,6 +57,7 @@ from api.v1.serializer_utils.lighthouse import (
)
from api.v1.serializer_utils.processors import ProcessorConfigField
from api.v1.serializer_utils.providers import ProviderSecretField
from api.validators import validate_lighthouse_openai_compatible_base_url
from django.conf import settings
from django.contrib.auth import authenticate
from django.contrib.auth.models import update_last_login
@@ -80,6 +81,21 @@ from rest_framework_simplejwt.utils import get_md5_hash_password
# Base
def _validate_lighthouse_base_url_without_dns(base_url: str) -> None:
try:
validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
except DjangoValidationError as error:
raise ValidationError({"base_url": error.messages[0]}) from error
def _reraise_lighthouse_credentials_errors(error: ValidationError) -> None:
details = error.detail.copy()
for key, value in details.items():
error.detail[f"credentials/{key}"] = value
del error.detail[key]
raise error
class BaseModelSerializerV1(serializers.ModelSerializer):
def get_root_meta(self, _resource, _many):
return {"version": "v1"}
@@ -3645,11 +3661,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
_reraise_lighthouse_credentials_errors(e)
elif (
provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
):
@@ -3658,27 +3670,20 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
_reraise_lighthouse_credentials_errors(e)
elif (
provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
if not base_url:
raise ValidationError({"base_url": "Base URL is required."})
_validate_lighthouse_base_url_without_dns(base_url)
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
_reraise_lighthouse_credentials_errors(e)
return super().validate(attrs)
@@ -3741,11 +3746,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
_reraise_lighthouse_credentials_errors(e)
elif (
credentials is not None
and provider_type
@@ -3769,11 +3770,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
_reraise_lighthouse_credentials_errors(e)
# Then enforce invariants about not changing the auth method
# If the existing config uses an API key, forbid introducing access keys.
@@ -3800,24 +3797,23 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
}
)
elif (
credentials is not None
and provider_type
provider_type
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
):
if base_url is None:
pass
elif not base_url:
effective_base_url = (
base_url if "base_url" in attrs else getattr(self.instance, "base_url")
)
if not effective_base_url:
raise ValidationError({"base_url": "Base URL cannot be empty."})
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
details = e.detail.copy()
for key, value in details.items():
e.detail[f"credentials/{key}"] = value
del e.detail[key]
raise e
if "base_url" in attrs:
_validate_lighthouse_base_url_without_dns(effective_base_url)
if credentials is not None:
try:
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
raise_exception=True
)
except ValidationError as e:
_reraise_lighthouse_credentials_errors(e)
return super().validate(attrs)
+130
View File
@@ -1,14 +1,140 @@
import ipaddress
import socket
import string
from urllib.parse import urlparse
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _
LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"})
LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
{
"169.254.169.254",
"169.254.170.2",
"fd00:ec2::254",
"localhost",
"metadata.google.internal",
}
)
def _normalize_hostname(hostname: str) -> str:
return hostname.rstrip(".").lower()
def _validate_lighthouse_public_ip(address: str) -> None:
ip_address = ipaddress.ip_address(address)
if isinstance(ip_address, ipaddress.IPv6Address):
# Classify transition addresses by their effective IPv4 destination.
embedded_ip_address = ip_address.ipv4_mapped or ip_address.sixtofour
if (
embedded_ip_address is None
and ip_address in LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX
):
embedded_ip_address = ipaddress.IPv4Address(int(ip_address) & 0xFFFFFFFF)
if embedded_ip_address is not None:
ip_address = embedded_ip_address
if not ip_address.is_global:
raise ValidationError(
_("Base URL must use an external public endpoint."),
code="lighthouse_base_url_not_public",
)
def resolve_lighthouse_openai_compatible_host(
hostname: str,
port: int,
*,
resolve_dns: bool = True,
) -> tuple[str, ...]:
"""Return public IP addresses that are safe for Lighthouse outbound use."""
hostname = _normalize_hostname(hostname)
if hostname in LIGHTHOUSE_BLOCKED_METADATA_HOSTS or hostname.endswith(".localhost"):
raise ValidationError(
_("Base URL must use an external public endpoint."),
code="lighthouse_base_url_blocked_host",
)
try:
_validate_lighthouse_public_ip(hostname)
except ValueError:
if not resolve_dns:
return ()
else:
return (hostname,)
try:
resolved_addresses = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
except socket.gaierror as error:
raise ValidationError(
_("Base URL host could not be resolved."),
code="lighthouse_base_url_resolution_failed",
) from error
if not resolved_addresses:
raise ValidationError(
_("Base URL host could not be resolved."),
code="lighthouse_base_url_resolution_failed",
)
public_addresses: list[str] = []
for resolved_address in resolved_addresses:
socket_address = resolved_address[4]
resolved_ip_address = socket_address[0]
_validate_lighthouse_public_ip(resolved_ip_address)
if resolved_ip_address not in public_addresses:
public_addresses.append(resolved_ip_address)
return tuple(public_addresses)
def validate_lighthouse_openai_compatible_base_url(
base_url: str,
*,
resolve_dns: bool = True,
) -> None:
"""Validate an OpenAI-compatible Lighthouse base URL before outbound use."""
parsed = urlparse(str(base_url))
if parsed.scheme.lower() not in LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES:
raise ValidationError(
_("Base URL must use HTTPS."),
code="lighthouse_base_url_invalid_scheme",
)
if not parsed.hostname:
raise ValidationError(
_("Base URL must include a host."),
code="lighthouse_base_url_missing_host",
)
try:
port = parsed.port
except ValueError as error:
raise ValidationError(
_("Base URL port is invalid."),
code="lighthouse_base_url_invalid_port",
) from error
if port is not None and not 1 <= port <= 65535:
raise ValidationError(
_("Base URL port is invalid."),
code="lighthouse_base_url_invalid_port",
)
resolve_lighthouse_openai_compatible_host(
parsed.hostname,
port or 443,
resolve_dns=resolve_dns,
)
class MaximumLengthValidator:
def __init__(self, max_length=72):
self.max_length = max_length
def validate(self, password, user=None):
del user
if len(password) > self.max_length:
raise ValidationError(
_(
@@ -31,6 +157,7 @@ class SpecialCharactersValidator:
self.min_special_characters = min_special_characters
def validate(self, password, user=None):
del user
if (
sum(1 for char in password if char in self.special_characters)
< self.min_special_characters
@@ -55,6 +182,7 @@ class UppercaseValidator:
self.min_uppercase = min_uppercase
def validate(self, password, user=None):
del user
if sum(1 for char in password if char.isupper()) < self.min_uppercase:
raise ValidationError(
_(
@@ -75,6 +203,7 @@ class LowercaseValidator:
self.min_lowercase = min_lowercase
def validate(self, password, user=None):
del user
if sum(1 for char in password if char.islower()) < self.min_lowercase:
raise ValidationError(
_(
@@ -95,6 +224,7 @@ class NumericValidator:
self.min_numeric = min_numeric
def validate(self, password, user=None):
del user
if sum(1 for char in password if char.isdigit()) < self.min_numeric:
raise ValidationError(
_(
@@ -1,6 +1,15 @@
import ssl
from collections.abc import Iterable
import boto3
import httpcore
import httpx
import openai
from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
from api.validators import (
resolve_lighthouse_openai_compatible_host,
validate_lighthouse_openai_compatible_base_url,
)
from botocore import UNSIGNED
from botocore.config import Config
from botocore.exceptions import BotoCoreError, ClientError
@@ -43,6 +52,90 @@ EXCLUDED_OPENAI_MODEL_SUBSTRINGS = (
"-instruct", # Legacy instruct models (gpt-3.5-turbo-instruct, etc.)
)
OPENAI_COMPATIBLE_AUTHENTICATION_ERROR = "API key is invalid or missing"
OPENAI_COMPATIBLE_CONNECTION_ERROR = "Provider connection failed"
class _OpenAICompatibleProviderError(Exception):
"""Sanitized OpenAI-compatible provider error safe for task results."""
def _sanitize_openai_compatible_error(error: Exception) -> str:
status_code = getattr(error, "status_code", None)
if status_code is None:
response = getattr(error, "response", None)
status_code = getattr(response, "status_code", None)
if status_code == 401:
return OPENAI_COMPATIBLE_AUTHENTICATION_ERROR
return OPENAI_COMPATIBLE_CONNECTION_ERROR
class _LighthouseOpenAICompatibleNetworkBackend(httpcore.SyncBackend):
"""Validate and pin DNS results immediately before TCP connections."""
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
resolved_addresses = resolve_lighthouse_openai_compatible_host(host, port)
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
for address in resolved_addresses:
try:
return super().connect_tcp(
address,
port,
timeout=timeout,
local_address=local_address,
socket_options=socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
last_error = error
if last_error:
raise last_error
raise httpcore.ConnectError("No resolved addresses are available")
class _LighthouseOpenAICompatibleHTTPTransport(httpx.HTTPTransport):
"""HTTP transport that connects only to validated public IP addresses."""
def __init__(self) -> None:
self._pool = httpcore.ConnectionPool(
ssl_context=ssl.create_default_context(),
network_backend=_LighthouseOpenAICompatibleNetworkBackend(),
)
def _create_openai_compatible_http_client() -> httpx.Client:
"""Create the restricted HTTP client used for OpenAI-compatible providers."""
return httpx.Client(
follow_redirects=False,
trust_env=False,
transport=_LighthouseOpenAICompatibleHTTPTransport(),
)
def _list_openai_compatible_models(base_url: str, api_key: str):
validate_lighthouse_openai_compatible_base_url(base_url)
try:
with _create_openai_compatible_http_client() as http_client:
client = openai.OpenAI(
api_key=api_key,
base_url=base_url,
http_client=http_client,
)
return client.models.list()
except Exception as error:
raise _OpenAICompatibleProviderError(
_sanitize_openai_compatible_error(error)
) from error
def _extract_error_message(e: Exception) -> str:
"""
@@ -114,6 +207,7 @@ def _extract_openai_compatible_params(
return None
if not isinstance(base_url, str) or not base_url:
return None
validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
return {"base_url": base_url, "api_key": api_key}
@@ -285,13 +379,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> dict:
"error": "Base URL or API key is invalid or missing",
}
# Test connection using OpenAI SDK with custom base_url
# Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
client = openai.OpenAI(
api_key=params["api_key"],
base_url=params["base_url"],
)
_ = client.models.list()
_ = _list_openai_compatible_models(params["base_url"], params["api_key"])
else:
return {"connected": False, "error": "Unsupported provider type"}
@@ -361,8 +449,7 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> dict[str, st
Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
"""
client = openai.OpenAI(api_key=api_key, base_url=base_url)
models = client.models.list()
models = _list_openai_compatible_models(base_url, api_key)
available_models: dict[str, str] = {}
for model in models.data:
+265 -3
View File
@@ -3,6 +3,7 @@ from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import httpx
import openai
import pytest
from api.models import (
@@ -20,6 +21,7 @@ from django_celery_results.models import TaskResult
from tasks.jobs.lighthouse_providers import (
_create_bedrock_client,
_extract_bedrock_credentials,
_LighthouseOpenAICompatibleNetworkBackend,
)
from tasks.tasks import (
DJANGO_TMP_OUTPUT_DIRECTORY,
@@ -1566,7 +1568,7 @@ class TestCheckLighthouseProviderConnectionTask:
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-test123"},
"https://openrouter.ai/api/v1",
"https://93.184.216.34/api/v1",
{"connected": True, "error": None},
),
(
@@ -1641,7 +1643,7 @@ class TestCheckLighthouseProviderConnectionTask:
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-invalid"},
"https://openrouter.ai/api/v1",
"https://93.184.216.34/api/v1",
openai.APIConnectionError(request=MagicMock()),
),
(
@@ -1755,6 +1757,166 @@ class TestCheckLighthouseProviderConnectionTask:
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_openai_compatible_connection_rejects_metadata_base_url_without_request(
self, tenants_fixture
):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://169.254.169.254/latest/meta-data",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
eager_result = check_lighthouse_provider_connection_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result["connected"] is False
assert "base url" in result["error"].lower()
mock_openai.assert_not_called()
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_openai_compatible_connection_disables_redirects(self, tenants_fixture):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://93.184.216.34/api/v1",
is_active=False,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.return_value = MagicMock()
mock_openai.return_value = mock_client
eager_result = check_lighthouse_provider_connection_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result == {"connected": True, "error": None}
http_client = mock_openai.call_args.kwargs["http_client"]
assert http_client.follow_redirects is False
assert http_client.trust_env is False
def test_openai_compatible_connection_masks_remote_http_error(
self, tenants_fixture
):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://93.184.216.34/api/v1",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
remote_body = "<!DOCTYPE HTML><p>remote 404 body</p>"
response = httpx.Response(
404,
request=httpx.Request("GET", "https://provider.example/v1/models"),
)
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.side_effect = openai.NotFoundError(
remote_body,
response=response,
body=remote_body,
)
mock_openai.return_value = mock_client
eager_result = check_lighthouse_provider_connection_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result == {"connected": False, "error": "Provider connection failed"}
assert remote_body not in result["error"]
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_openai_compatible_connection_masks_remote_auth_error(
self, tenants_fixture
):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://93.184.216.34/api/v1",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
remote_body = {"error": {"message": "remote auth detail"}}
response = httpx.Response(
401,
request=httpx.Request("GET", "https://provider.example/v1/models"),
)
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.side_effect = openai.AuthenticationError(
"Unauthorized",
response=response,
body=remote_body,
)
mock_openai.return_value = mock_client
eager_result = check_lighthouse_provider_connection_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result == {"connected": False, "error": "API key is invalid or missing"}
assert "remote auth detail" not in result["error"]
provider_cfg.refresh_from_db()
assert provider_cfg.is_active is False
def test_openai_compatible_network_backend_uses_validated_ip(self, monkeypatch):
backend = _LighthouseOpenAICompatibleNetworkBackend()
stream = MagicMock()
def resolve_to_public_ip(host, port):
del host, port
return ("93.184.216.34",)
monkeypatch.setattr(
"tasks.jobs.lighthouse_providers.resolve_lighthouse_openai_compatible_host",
resolve_to_public_ip,
)
with patch(
"tasks.jobs.lighthouse_providers.httpcore.SyncBackend.connect_tcp",
return_value=stream,
) as mock_connect_tcp:
result = backend.connect_tcp("provider.example", 443, timeout=1.0)
assert result is stream
assert mock_connect_tcp.call_args.args[:2] == ("93.184.216.34", 443)
assert mock_connect_tcp.call_args.kwargs["timeout"] == 1.0
def test_check_connection_provider_does_not_exist(self, tenants_fixture):
"""Test that checking non-existent provider raises DoesNotExist."""
non_existent_id = str(uuid.uuid4())
@@ -1784,7 +1946,7 @@ class TestRefreshLighthouseProviderModelsTask:
(
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
{"api_key": "sk-test123"},
"https://openrouter.ai/api/v1",
"https://93.184.216.34/api/v1",
{"model-1": "Model One", "model-2": "Model Two"},
2,
),
@@ -1864,6 +2026,106 @@ class TestRefreshLighthouseProviderModelsTask:
== expected_count
)
def test_refresh_models_rejects_metadata_base_url_without_request(
self, tenants_fixture
):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://169.254.169.254/latest/meta-data",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
with patch(
"tasks.jobs.lighthouse_providers._fetch_openai_compatible_models"
) as mock_fetch:
eager_result = refresh_lighthouse_provider_models_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result["created"] == 0
assert result["updated"] == 0
assert result["deleted"] == 0
assert "base url" in result["error"].lower()
mock_fetch.assert_not_called()
def test_refresh_models_disables_redirects(self, tenants_fixture):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://93.184.216.34/api/v1",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.return_value = MagicMock(data=[])
mock_openai.return_value = mock_client
eager_result = refresh_lighthouse_provider_models_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result["created"] == 0
assert result["updated"] == 0
assert result["deleted"] == 0
http_client = mock_openai.call_args.kwargs["http_client"]
assert http_client.follow_redirects is False
assert http_client.trust_env is False
def test_refresh_models_masks_remote_http_error(self, tenants_fixture):
provider_cfg = LighthouseProviderConfiguration(
tenant_id=tenants_fixture[0].id,
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
base_url="https://93.184.216.34/api/v1",
is_active=True,
)
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
provider_cfg.save()
remote_body = "<!DOCTYPE HTML><p>remote 404 body</p>"
response = httpx.Response(
404,
request=httpx.Request("GET", "https://provider.example/v1/models"),
)
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
mock_client = MagicMock()
mock_client.models.list.side_effect = openai.NotFoundError(
remote_body,
response=response,
body=remote_body,
)
mock_openai.return_value = mock_client
eager_result = refresh_lighthouse_provider_models_task.apply(
kwargs={
"provider_config_id": str(provider_cfg.id),
"tenant_id": str(tenants_fixture[0].id),
}
)
assert eager_result.successful()
result = eager_result.result
assert result["created"] == 0
assert result["updated"] == 0
assert result["deleted"] == 0
assert result["error"] == "Provider connection failed"
assert remote_body not in result["error"]
def test_refresh_models_mixed_operations(self, tenants_fixture):
"""Test mixed create, update, and delete operations."""
# Create provider configuration