7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
274 lines
9.8 KiB
Python
274 lines
9.8 KiB
Python
"""
|
|
Coverage tests for local_deep_research/llm/providers/openai_base.py
|
|
|
|
Tests focus on branches NOT already exercised by tests/llm_providers/test_openai_base.py:
|
|
- create_llm() raises ValueError when api_key_setting is set but key is missing
|
|
- create_llm() uses 'dummy-key' when api_key_setting is None (local providers)
|
|
- create_llm() respects default_model when model_name is None
|
|
- create_llm() passes base_url kwarg override correctly
|
|
- _create_llm_instance() uses dummy-key by default
|
|
- is_available() returns True when api_key_setting is None
|
|
- is_available() returns False when key is blank/missing
|
|
- requires_auth_for_models() returns True by default
|
|
- _get_base_url_for_models() returns default_base_url when url_setting is None
|
|
- _get_base_url_for_models() returns configured URL when url_setting is present
|
|
- list_models_for_api() returns [] when auth required but api_key is None
|
|
- list_models() propagates exceptions as []
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from local_deep_research.llm.providers.openai_base import (
|
|
OpenAICompatibleProvider,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subclass helpers for testing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _NoKeyProvider(OpenAICompatibleProvider):
|
|
"""Provider that doesn't require an API key (like LM Studio)."""
|
|
|
|
provider_name = "no_key_provider"
|
|
api_key_setting = None
|
|
default_base_url = "http://localhost:1234/v1"
|
|
default_model = "local-model"
|
|
|
|
|
|
class _KeyedProvider(OpenAICompatibleProvider):
|
|
"""Provider that requires an API key."""
|
|
|
|
provider_name = "keyed_provider"
|
|
api_key_setting = "llm.keyed_provider.api_key"
|
|
default_base_url = "https://api.example.com/v1"
|
|
default_model = "default-model"
|
|
|
|
|
|
class _UrlSettingProvider(OpenAICompatibleProvider):
|
|
"""Provider with a configurable URL setting."""
|
|
|
|
provider_name = "url_provider"
|
|
api_key_setting = None
|
|
url_setting = "llm.url_provider.url"
|
|
default_base_url = "http://default.local/v1"
|
|
default_model = "url-model"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create_llm()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateLlm:
|
|
def test_raises_when_api_key_missing(self):
|
|
"""If api_key_setting is set but key not found, ValueError is raised."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with pytest.raises(ValueError, match="not configured"):
|
|
_KeyedProvider.create_llm()
|
|
|
|
def test_no_api_key_setting_uses_dummy_key(self):
|
|
"""Providers with api_key_setting=None get a dummy key."""
|
|
mock_llm = MagicMock()
|
|
with (
|
|
patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"local_deep_research.llm.providers.openai_base.ChatOpenAI",
|
|
return_value=mock_llm,
|
|
) as MockChat,
|
|
):
|
|
_NoKeyProvider.create_llm(model_name="test-model")
|
|
# dummy-key was passed
|
|
call_kwargs = MockChat.call_args[1]
|
|
assert call_kwargs["api_key"] == "not-required"
|
|
|
|
def test_uses_default_model_when_none_given(self):
|
|
"""model_name=None → raises ValueError (no silent default)."""
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
with pytest.raises(ValueError, match="model not configured"):
|
|
_NoKeyProvider.create_llm(model_name=None)
|
|
|
|
def test_base_url_kwarg_overrides_default(self):
|
|
"""Passing base_url kwarg overrides cls.default_base_url."""
|
|
mock_llm = MagicMock()
|
|
with (
|
|
patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"local_deep_research.llm.providers.openai_base.normalize_url",
|
|
side_effect=lambda u: u,
|
|
),
|
|
patch(
|
|
"local_deep_research.llm.providers.openai_base.ChatOpenAI",
|
|
return_value=mock_llm,
|
|
) as MockChat,
|
|
):
|
|
_NoKeyProvider.create_llm(
|
|
model_name="test-model", base_url="http://custom.local/v1"
|
|
)
|
|
call_kwargs = MockChat.call_args[1]
|
|
assert "custom.local" in call_kwargs["base_url"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _create_llm_instance()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCreateLlmInstance:
|
|
def test_uses_dummy_key_by_default(self):
|
|
mock_llm = MagicMock()
|
|
with (
|
|
patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"local_deep_research.llm.providers.openai_base.ChatOpenAI",
|
|
return_value=mock_llm,
|
|
) as MockChat,
|
|
):
|
|
_NoKeyProvider._create_llm_instance(model_name="test-model")
|
|
call_kwargs = MockChat.call_args[1]
|
|
assert call_kwargs["api_key"] == "not-required"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_available()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestIsAvailable:
|
|
def test_no_key_setting_always_available(self):
|
|
assert _NoKeyProvider.is_available() is True
|
|
|
|
def test_keyed_provider_available_when_key_present(self):
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value="sk-real-key",
|
|
):
|
|
assert _KeyedProvider.is_available() is True
|
|
|
|
def test_keyed_provider_not_available_when_key_missing(self):
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=None,
|
|
):
|
|
assert _KeyedProvider.is_available() is False
|
|
|
|
def test_keyed_provider_not_available_when_key_blank(self):
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value=" ",
|
|
):
|
|
assert _KeyedProvider.is_available() is False
|
|
|
|
def test_exception_during_check_returns_false(self):
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
side_effect=Exception("boom"),
|
|
):
|
|
assert _KeyedProvider.is_available() is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# requires_auth_for_models()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRequiresAuthForModels:
|
|
def test_default_returns_true(self):
|
|
assert OpenAICompatibleProvider.requires_auth_for_models() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_base_url_for_models()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetBaseUrlForModels:
|
|
def test_returns_default_url_when_no_url_setting(self):
|
|
url = _KeyedProvider._get_base_url_for_models()
|
|
assert url == _KeyedProvider.default_base_url
|
|
|
|
def test_returns_configured_url_when_url_setting_present(self):
|
|
with patch(
|
|
"local_deep_research.config.thread_settings.get_setting_from_snapshot",
|
|
return_value="http://configured.local/v1",
|
|
):
|
|
url = _UrlSettingProvider._get_base_url_for_models()
|
|
assert "configured.local" in url
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_models_for_api()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListModelsForApi:
|
|
def test_returns_empty_list_when_auth_required_but_no_key(self):
|
|
result = _KeyedProvider.list_models_for_api(api_key=None)
|
|
assert result == []
|
|
|
|
def test_returns_empty_list_on_openai_exception(self):
|
|
mock_client = MagicMock()
|
|
mock_client.models.list.side_effect = Exception("network error")
|
|
with patch(
|
|
"openai.OpenAI",
|
|
return_value=mock_client,
|
|
):
|
|
result = _NoKeyProvider.list_models_for_api(api_key="dummy")
|
|
assert result == []
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_key",
|
|
[
|
|
{"llm.openai.api_key": "sk-leaked"}, # the issue #3800 case
|
|
123,
|
|
b"bytes-not-string",
|
|
["list", "of", "things"],
|
|
],
|
|
ids=["dict", "int", "bytes", "list"],
|
|
)
|
|
def test_rejects_non_string_api_key_without_calling_sdk(self, bad_key):
|
|
"""Defense-in-depth (issue #3800): a non-string api_key would land
|
|
in ``Authorization: Bearer <repr>`` and leak its contents to the
|
|
endpoint. The provider must refuse before constructing the client.
|
|
"""
|
|
with patch("openai.OpenAI") as mock_openai:
|
|
result = _NoKeyProvider.list_models_for_api(
|
|
api_key=bad_key, base_url="http://localhost:1234/v1"
|
|
)
|
|
assert result == []
|
|
mock_openai.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_models()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestListModels:
|
|
def test_returns_empty_list_on_exception(self):
|
|
with patch.object(
|
|
_KeyedProvider,
|
|
"list_models_for_api",
|
|
side_effect=Exception("fail"),
|
|
):
|
|
result = _KeyedProvider.list_models()
|
|
assert result == []
|