Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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 == []