Coverage for src/local_deep_research/llm/providers/implementations/custom_openai_endpoint.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""Custom OpenAI-compatible endpoint provider for Local Deep Research."""
3from ....security.secure_logging import logger
5from ....config.thread_settings import get_setting_from_snapshot
6from ....security.log_sanitizer import redact_secrets
7from ....utilities.url_utils import normalize_url
8from ..base import Exposure
9from ..openai_base import OpenAICompatibleProvider
12class CustomOpenAIEndpointProvider(OpenAICompatibleProvider):
13 """Custom OpenAI-compatible endpoint provider.
15 This provider allows users to connect to any OpenAI-compatible API endpoint
16 by specifying a custom URL in the settings.
17 """
19 provider_name = "OpenAI-Compatible Endpoint"
20 api_key_setting = "llm.openai_endpoint.api_key"
21 # Many OpenAI-compatible servers (vLLM, local LLMs, etc.) don't require
22 # auth. Optional flag lets the resolver fall back to a placeholder when
23 # no key is configured, instead of raising at LLM construction time.
24 api_key_optional = True
25 url_setting = "llm.openai_endpoint.url" # type: ignore[assignment] # Settings key for URL
26 default_base_url = "https://api.openai.com/v1"
27 default_model = "" # User must explicitly pick a model — no silent fallback
29 # Metadata for auto-discovery
30 provider_key = "OPENAI_ENDPOINT"
31 company_name = "OpenAI-Compatible"
32 is_cloud = None # Unknown — could be local or cloud
33 egress_exposure = Exposure.EXPOSING # URL-configurable; fail closed to exposing until classifier refines by URL
35 @classmethod
36 def requires_auth_for_models(cls):
37 """Custom endpoints may or may not require authentication for listing models.
39 Many OpenAI-compatible servers (vLLM, local LLMs, etc.) don't require
40 authentication. Return False to allow model listing without an API key.
41 If the endpoint requires auth, the OpenAI client will raise an error.
42 """
43 return False
45 @classmethod
46 def is_available(cls, settings_snapshot=None):
47 """Custom endpoints are available with either an API key or a custom URL.
49 Unlike cloud-only providers, custom endpoints support keyless local
50 servers (vLLM, text-generation-webui, etc.). The provider is
51 considered configured when the user has set either an API key or a
52 URL that differs from the default OpenAI endpoint.
53 """
54 api_key = None
55 try:
56 api_key = get_setting_from_snapshot(
57 cls.api_key_setting,
58 default=None,
59 settings_snapshot=settings_snapshot,
60 )
61 if api_key and str(api_key).strip():
62 return True
63 except Exception as e:
64 # Drop exc_info — the cause chain may embed the api_key value
65 # if a settings-layer error message surfaces it. Interpolate
66 # a redacted exception message instead.
67 safe_msg = redact_secrets(str(e), api_key)
68 logger.debug(f"Error checking provider availability: {safe_msg}")
70 try:
71 custom_url = get_setting_from_snapshot(
72 cls.url_setting,
73 default=None,
74 settings_snapshot=settings_snapshot,
75 )
76 if custom_url and str(custom_url).strip():
77 normalized = normalize_url(str(custom_url).strip())
78 if normalized.rstrip("/") != cls.default_base_url.rstrip("/"):
79 return True
80 except Exception:
81 logger.debug(
82 f"Error reading URL setting '{cls.url_setting}'",
83 exc_info=True,
84 )
86 return False
88 @classmethod
89 def create_llm(cls, model_name=None, temperature=0.7, **kwargs):
90 """Override to get URL from settings."""
91 settings_snapshot = kwargs.get("settings_snapshot")
93 # Keyless construction is supported (vLLM, text-generation-webui),
94 # but a key-requiring endpoint (e.g. OpenRouter) fails with an
95 # opaque upstream 401 — warn so the misconfiguration is traceable.
96 if cls.resolve_api_key(settings_snapshot) is None:
97 logger.warning(
98 "No API key configured for openai_endpoint provider; "
99 "proceeding with a placeholder. If your endpoint requires "
100 "an API key, set llm.openai_endpoint.api_key in settings."
101 )
103 # Get custom endpoint URL from settings
104 custom_url = get_setting_from_snapshot(
105 "llm.openai_endpoint.url",
106 default=cls.default_base_url,
107 settings_snapshot=settings_snapshot,
108 )
110 # Normalize and pass the custom URL to parent implementation
111 kwargs["base_url"] = (
112 normalize_url(custom_url) if custom_url else cls.default_base_url
113 )
115 # Opt-in token usage on streamed responses. Off by default because
116 # some OpenAI-compatible gateways reject stream_options with a 400
117 # (e.g. xAI, Databricks AI Gateway, Azure "on your data"), which
118 # would break the call entirely — worse than missing token counts.
119 stream_usage = get_setting_from_snapshot(
120 "llm.openai_endpoint.stream_usage",
121 default=False,
122 settings_snapshot=settings_snapshot,
123 )
124 if stream_usage:
125 kwargs["stream_usage"] = True
127 return super().create_llm(model_name, temperature, **kwargs)