diff --git a/web/config.py b/web/config.py index c8ab16233bb..70571c07ac5 100644 --- a/web/config.py +++ b/web/config.py @@ -1058,11 +1058,20 @@ # This prevents SSRF attacks via user-controlled API URL fields. # Set to an empty list to disable URL restriction (not recommended). # Add entries for custom providers (LiteLLM, LM Studio, corporate proxies). +# +# Use ``:*`` to match any port on a host — useful for local self-hosting +# where the user chooses the port. The host check still rejects +# link-local addresses like 169.254.169.254 used by cloud metadata. ALLOWED_LLM_API_URLS = [ 'https://api.anthropic.com:443', 'https://api.openai.com:443', - 'http://localhost:11434', # Ollama default - 'http://localhost:12434', # Docker Model Runner default + # Loopback addresses on any port: covers Ollama (11434), Docker + # Model Runner (12434), LiteLLM (4000), vLLM (8000), LM Studio + # (1234), text-generation-webui (5000), and any self-hosted + # OpenAI-compatible endpoint the user runs on their own machine. + 'http://localhost:*', + 'http://127.0.0.1:*', + 'http://[::1]:*', ] # Maximum Tool Iterations diff --git a/web/pgadmin/llm/client.py b/web/pgadmin/llm/client.py index e860eec4497..3c3dc823858 100644 --- a/web/pgadmin/llm/client.py +++ b/web/pgadmin/llm/client.py @@ -172,7 +172,9 @@ def get_llm_client( get_anthropic_api_url, get_anthropic_api_key, get_anthropic_model, get_openai_api_url, get_openai_api_key, get_openai_model, get_ollama_api_url, get_ollama_model, - get_docker_api_url, get_docker_model + get_docker_api_url, get_docker_model, + is_pref_api_url_rejected, + is_pref_api_key_path_rejected, ) # Determine which provider to use @@ -183,8 +185,34 @@ def get_llm_client( provider = provider.lower() + def _rejected_url_error(prov): + return LLMClientError(LLMError( + message=( + f"The configured {prov} API URL is not in the " + "allowed list (ALLOWED_LLM_API_URLS). Add it to " + "config_local.py, or clear the API URL preference " + "to use the system default." + ), + provider=prov, + )) + + def _rejected_key_file_error(prov): + return LLMClientError(LLMError( + message=( + f"The configured {prov} API key file is not within " + "your private user storage. Move the key file to " + "your private storage directory, or clear the API " + "Key File preference to use the system default." + ), + provider=prov, + )) + if provider == 'anthropic': from pgadmin.llm.providers.anthropic import AnthropicClient + if is_pref_api_url_rejected('anthropic_api_url'): + raise _rejected_url_error('anthropic') + if is_pref_api_key_path_rejected('anthropic_api_key_file'): + raise _rejected_key_file_error('anthropic') api_key = get_anthropic_api_key() api_url = get_anthropic_api_url() if not api_key and not api_url: @@ -199,6 +227,10 @@ def get_llm_client( elif provider == 'openai': from pgadmin.llm.providers.openai import OpenAIClient + if is_pref_api_url_rejected('openai_api_url'): + raise _rejected_url_error('openai') + if is_pref_api_key_path_rejected('openai_api_key_file'): + raise _rejected_key_file_error('openai') api_key = get_openai_api_key() api_url = get_openai_api_url() if not api_key and not api_url: @@ -213,6 +245,8 @@ def get_llm_client( elif provider == 'ollama': from pgadmin.llm.providers.ollama import OllamaClient + if is_pref_api_url_rejected('ollama_api_url'): + raise _rejected_url_error('ollama') api_url = get_ollama_api_url() if not api_url: raise LLMClientError(LLMError( @@ -224,6 +258,8 @@ def get_llm_client( elif provider == 'docker': from pgadmin.llm.providers.docker import DockerClient + if is_pref_api_url_rejected('docker_api_url'): + raise _rejected_url_error('docker') api_url = get_docker_api_url() if not api_url: raise LLMClientError(LLMError( diff --git a/web/pgadmin/llm/tests/test_api_url_validation.py b/web/pgadmin/llm/tests/test_api_url_validation.py index d3a73322689..2229dbe0c16 100644 --- a/web/pgadmin/llm/tests/test_api_url_validation.py +++ b/web/pgadmin/llm/tests/test_api_url_validation.py @@ -153,6 +153,51 @@ class ValidateApiUrlTestCase(BaseTestGenerator): ], expected=False, )), + ('Wildcard port: localhost:* matches arbitrary port', dict( + url='http://localhost:4000/v1/chat/completions', + allowed_urls=[ + 'http://localhost:*', + ], + expected=True, + )), + ('Wildcard port: same host, default 80 also matches', dict( + url='http://localhost/v1', + allowed_urls=[ + 'http://localhost:*', + ], + expected=True, + )), + ('Wildcard port: 127.0.0.1:* matches', dict( + url='http://127.0.0.1:8080/foo', + allowed_urls=[ + 'http://127.0.0.1:*', + ], + expected=True, + )), + ('Wildcard port: IPv6 loopback matches', dict( + url='http://[::1]:8000/foo', + allowed_urls=[ + 'http://[::1]:*', + ], + expected=True, + )), + ('Wildcard port does NOT cover other hosts', dict( + # Cloud metadata endpoint stays blocked even with + # localhost:* in the allowlist. + url='http://169.254.169.254/latest/meta-data/', + allowed_urls=[ + 'http://localhost:*', + 'http://127.0.0.1:*', + ], + expected=False, + )), + ('Wildcard port: scheme still enforced', dict( + url='https://localhost:8443/v1', + allowed_urls=[ + 'http://localhost:*', + ], + expected=False, + )), ] def setUp(self): @@ -166,14 +211,20 @@ def runTest(self): self.assertEqual(result, self.expected) -class GetApiUrlPreferenceRejectionTestCase(BaseTestGenerator): - """Test that get_*_api_url() rejects non-allowlisted preference - URLs and falls through to system config.""" +class GetApiUrlResolutionTestCase(BaseTestGenerator): + """Test the get_*_api_url() resolution rules. + + Contract (post-#9936): + - Pref URL set and allowed -> pref URL returned + - Pref URL set but rejected -> '' returned (NO fallback to + admin default; otherwise the user's request would be + silently routed to a different provider) + - Pref URL unset -> admin's config URL returned + """ scenarios = [ ('Anthropic: allowed pref URL returned', dict( getter='get_anthropic_api_url', - pref_name='anthropic_api_url', pref_value='https://api.anthropic.com/v1', config_attr='ANTHROPIC_API_URL', config_value='https://fallback.example.com/v1', @@ -182,49 +233,65 @@ class GetApiUrlPreferenceRejectionTestCase(BaseTestGenerator): ], expect='https://api.anthropic.com/v1', )), - ('Anthropic: disallowed pref falls to config', dict( + ('Anthropic: rejected pref returns "" (no fallback)', dict( getter='get_anthropic_api_url', - pref_name='anthropic_api_url', pref_value='http://169.254.169.254/', config_attr='ANTHROPIC_API_URL', config_value='https://fallback.example.com/v1', allowed_urls=[ 'https://api.anthropic.com:443', ], + expect='', + )), + ('Anthropic: unset pref returns admin config', dict( + getter='get_anthropic_api_url', + pref_value=None, + config_attr='ANTHROPIC_API_URL', + config_value='https://fallback.example.com/v1', + allowed_urls=[ + 'https://api.anthropic.com:443', + ], expect='https://fallback.example.com/v1', )), - ('Ollama: disallowed pref falls to config', dict( + ('Ollama: rejected pref returns "" (no fallback)', dict( getter='get_ollama_api_url', - pref_name='ollama_api_url', pref_value='http://10.0.0.1:8080/', config_attr='OLLAMA_API_URL', config_value='http://localhost:11434', allowed_urls=[ 'http://localhost:11434', ], - expect='http://localhost:11434', + expect='', )), - ('OpenAI: disallowed pref falls to config', dict( + ('OpenAI: rejected pref returns "" (no fallback)', dict( getter='get_openai_api_url', - pref_name='openai_api_url', pref_value='http://evil.com:9999/', config_attr='OPENAI_API_URL', - config_value='', + config_value='https://api.openai.com', allowed_urls=[ 'https://api.openai.com:443', ], expect='', )), - ('Docker: disallowed pref falls to config', dict( + ('Docker: rejected pref returns "" (no fallback)', dict( getter='get_docker_api_url', - pref_name='docker_api_url', pref_value='http://192.168.1.1:12434/', config_attr='DOCKER_API_URL', config_value='http://localhost:12434', allowed_urls=[ 'http://localhost:12434', ], - expect='http://localhost:12434', + expect='', + )), + ('LiteLLM-style pref on localhost:* allowlist', dict( + getter='get_openai_api_url', + pref_value='http://localhost:4000/v1', + config_attr='OPENAI_API_URL', + config_value='', + allowed_urls=[ + 'http://localhost:*', + ], + expect='http://localhost:4000/v1', )), ] @@ -245,3 +312,276 @@ def runTest(self): ): result = getter_fn() self.assertEqual(result, self.expect) + + +class IsPrefApiUrlRejectedTestCase(BaseTestGenerator): + """Test the is_pref_api_url_rejected() helper that client.py + uses to decide whether to surface a clear 'URL not allowed' + error instead of a generic 'not configured' error.""" + + scenarios = [ + ('No pref set', dict( + pref_value=None, + allowed_urls=['https://api.openai.com:443'], + expect=False, + )), + ('Pref allowed', dict( + pref_value='https://api.openai.com/v1', + allowed_urls=['https://api.openai.com:443'], + expect=False, + )), + ('Pref rejected', dict( + pref_value='https://litellm.example.com/v1', + allowed_urls=['https://api.openai.com:443'], + expect=True, + )), + ('Pref allowed by wildcard', dict( + pref_value='http://localhost:4000/v1', + allowed_urls=['http://localhost:*'], + expect=False, + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + + with patch( + 'config.ALLOWED_LLM_API_URLS', self.allowed_urls + ), patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ): + self.assertEqual( + llm_utils.is_pref_api_url_rejected('openai_api_url'), + self.expect, + ) + + +class GetApiKeyResolutionTestCase(BaseTestGenerator): + """Test the get_*_api_key() resolution rules. + + Contract (post-#9936 follow-up for jbro90's comment): + - Pref key file set and path allowed -> read user's key + - Pref key file set but path rejected -> return None (NO + fallback to admin default; silent substitution would route + the request with a different key) + - Pref key file unset -> read admin's + trusted config key file + """ + + scenarios = [ + ('Anthropic: rejected pref path returns None', dict( + getter='get_anthropic_api_key', + pref_value='/etc/passwd', # outside storage dir + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='', + path_valid=False, + read_admin_returns=None, + expect=None, + )), + ('OpenAI: rejected pref path returns None', dict( + getter='get_openai_api_key', + pref_value='/tmp/malicious.key', + config_attr='OPENAI_API_KEY_FILE', + config_value='/etc/pgadmin/admin.key', + path_valid=False, + # Even though admin has a valid key file configured, we + # do NOT silently substitute it — the user explicitly + # set their own preference. + read_admin_returns='sk-admin-fallback', + expect=None, + )), + ('Anthropic: allowed pref path returns user key', dict( + getter='get_anthropic_api_key', + pref_value='/home/u/private/anthropic.key', + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='', + path_valid=True, + read_admin_returns=None, + expect='sk-user-key', + )), + ('Anthropic: unset pref falls to admin default', dict( + getter='get_anthropic_api_key', + pref_value=None, + config_attr='ANTHROPIC_API_KEY_FILE', + config_value='/etc/pgadmin/admin.key', + path_valid=True, + read_admin_returns='sk-admin-fallback', + expect='sk-admin-fallback', + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + getter_fn = getattr(llm_utils, self.getter) + + validate_ret = ('/safe' + str(self.pref_value)) \ + if self.path_valid and self.pref_value else None + + def fake_read(path, _trusted=False): + # User pref read path is the validate_api_key_path return. + if _trusted: + return self.read_admin_returns + return 'sk-user-key' + + with patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ), patch.object( + llm_utils, 'validate_api_key_path', + return_value=validate_ret + ), patch.object( + llm_utils, '_read_api_key_from_file', + side_effect=fake_read + ), patch( + 'config.' + self.config_attr, self.config_value + ): + result = getter_fn() + self.assertEqual(result, self.expect) + + +class IsPrefApiKeyPathRejectedTestCase(BaseTestGenerator): + """Test the is_pref_api_key_path_rejected() helper.""" + + scenarios = [ + ('No pref set', dict( + pref_value=None, + path_valid=True, + expect=False, + )), + ('Pref allowed', dict( + pref_value='/home/u/private/key', + path_valid=True, + expect=False, + )), + ('Pref rejected', dict( + pref_value='/etc/passwd', + path_valid=False, + expect=True, + )), + ] + + def setUp(self): + pass + + def runTest(self): + import pgadmin.llm.utils as llm_utils + validate_ret = '/safe' if self.path_valid else None + + with patch.object( + llm_utils, '_get_preference_value', + return_value=self.pref_value + ), patch.object( + llm_utils, 'validate_api_key_path', + return_value=validate_ret + ): + self.assertEqual( + llm_utils.is_pref_api_key_path_rejected( + 'anthropic_api_key_file' + ), + self.expect, + ) + + +class GetLLMClientRejectedKeyFileTestCase(BaseTestGenerator): + """When the user's preference key file path is rejected by the + directory allowlist, get_llm_client() must raise a clear + LLMClientError mentioning private user storage rather than the + generic 'API key not configured' error.""" + + scenarios = [ + ('OpenAI rejected key file', dict( + provider='openai', + pref_key='openai_api_key_file', + pref_value='/etc/passwd', + )), + ('Anthropic rejected key file', dict( + provider='anthropic', + pref_key='anthropic_api_key_file', + pref_value='/tmp/leak.key', + )), + ] + + def setUp(self): + pass + + def runTest(self): + from pgadmin.llm.client import get_llm_client, LLMClientError + import pgadmin.llm.utils as llm_utils + + def fake_get_pref(name): + return self.pref_value if name == self.pref_key else None + + with patch.object( + llm_utils, '_get_preference_value', side_effect=fake_get_pref + ), patch.object( + llm_utils, 'validate_api_key_path', return_value=None + ): + with self.assertRaises(LLMClientError) as ctx: + get_llm_client(provider=self.provider) + + msg = str(ctx.exception) + self.assertIn('private', msg.lower()) + self.assertIn(self.provider, msg) + + +class GetLLMClientRejectedUrlTestCase(BaseTestGenerator): + """When the user's preference URL is blocked by the allowlist, + get_llm_client() must raise a clear LLMClientError mentioning + ALLOWED_LLM_API_URLS instead of a misleading + 'API key not configured' error.""" + + scenarios = [ + ('OpenAI rejected URL surfaces clear error', dict( + provider='openai', + pref_name='openai_api_url', + pref_value='https://litellm.example.com/v1', + allowed_urls=['https://api.openai.com:443'], + )), + ('Anthropic rejected URL surfaces clear error', dict( + provider='anthropic', + pref_name='anthropic_api_url', + pref_value='http://10.0.0.1:8080/', + allowed_urls=['https://api.anthropic.com:443'], + )), + ('Ollama rejected URL surfaces clear error', dict( + provider='ollama', + pref_name='ollama_api_url', + pref_value='http://corporate-proxy.example.com:8080/', + allowed_urls=['http://localhost:11434'], + )), + ('Docker rejected URL surfaces clear error', dict( + provider='docker', + pref_name='docker_api_url', + pref_value='http://192.168.1.1:12434/', + allowed_urls=['http://localhost:12434'], + )), + ] + + def setUp(self): + pass + + def runTest(self): + from pgadmin.llm.client import get_llm_client, LLMClientError + import pgadmin.llm.utils as llm_utils + + def fake_get_pref(name): + return self.pref_value if name == self.pref_name else None + + with patch( + 'config.ALLOWED_LLM_API_URLS', self.allowed_urls + ), patch.object( + llm_utils, '_get_preference_value', side_effect=fake_get_pref + ): + with self.assertRaises(LLMClientError) as ctx: + get_llm_client(provider=self.provider) + + msg = str(ctx.exception) + self.assertIn('ALLOWED_LLM_API_URLS', msg) + self.assertIn(self.provider, msg) diff --git a/web/pgadmin/llm/utils.py b/web/pgadmin/llm/utils.py index ed6cb0b90e6..c89b6c4f8c1 100644 --- a/web/pgadmin/llm/utils.py +++ b/web/pgadmin/llm/utils.py @@ -103,6 +103,44 @@ def validate_api_key_path(file_path): return None +def _parse_allowlist_entry(entry): + """ + Parse an allowlist entry into (scheme, hostname, port). + + ``port`` is the literal string ``'*'`` if the entry uses the + port wildcard (e.g. ``http://localhost:*``), otherwise an int. + + Returns None if the entry is malformed. + """ + from urllib.parse import urlparse + + port_wildcard = False + raw = entry + if raw.endswith(':*'): + port_wildcard = True + raw = raw[:-2] + + parsed = urlparse(raw) + scheme = parsed.scheme.lower() + hostname = parsed.hostname + if hostname: + hostname = hostname.lower() + + if not scheme or not hostname or scheme not in ('http', 'https'): + return None + + if port_wildcard: + return scheme, hostname, '*' + + try: + port = parsed.port + except ValueError: + return None + if port is None: + port = 443 if scheme == 'https' else 80 + return scheme, hostname, port + + def validate_api_url(url): """ Validate that a URL is in the allowed LLM API URL list. @@ -111,6 +149,13 @@ def validate_api_url(url): config.ALLOWED_LLM_API_URLS. Path is not checked — different providers use different paths. + Allowlist entries may use ``:*`` for the port to match any port + on that host (e.g. ``http://localhost:*`` matches localhost on + every port). This is useful for local self-hosting where the + user picks the port (LiteLLM, vLLM, LM Studio, etc.) — the + same host check still blocks link-local cloud metadata + endpoints like 169.254.169.254. + Returns True if the URL is allowed, False otherwise. An empty allowlist means no restriction (admin opt-out). """ @@ -144,27 +189,15 @@ def validate_api_url(url): if port is None: port = 443 if scheme == 'https' else 80 - request_origin = f'{scheme}://{hostname}:{port}' - for allowed in allowed_urls: - a_parsed = urlparse(allowed) - a_scheme = a_parsed.scheme.lower() - a_hostname = a_parsed.hostname - if a_hostname: - a_hostname = a_hostname.lower() - try: - a_port = a_parsed.port - except ValueError: + parsed_entry = _parse_allowlist_entry(allowed) + if parsed_entry is None: continue - if a_port is None: - if a_scheme in ('https', 'http'): - a_port = 443 if a_scheme == 'https' else 80 - else: - continue - - allowed_origin = f'{a_scheme}://{a_hostname}:{a_port}' + a_scheme, a_hostname, a_port = parsed_entry - if request_origin == allowed_origin: + if a_scheme != scheme or a_hostname != hostname: + continue + if a_port == '*' or a_port == port: return True return False @@ -252,47 +285,116 @@ def _get_preference_value(name): return None -def get_anthropic_api_url(): +def _resolve_pref_url(pref_name, config_default): """ - Get the Anthropic API URL. - - Checks user preferences first, then falls back to system configuration. - User-preference URLs are validated against the SSRF allowlist. + Resolve an API URL preference against the allowlist. - Returns: - The URL string, or empty string if not configured. + - User preference set and allowed: return it. + - User preference set but rejected: log a warning and return '' + WITHOUT falling back to the admin default. Silent substitution + hides the rejection and routes the user's request to a + different provider (issue #9936). + - No user preference: return the admin's trusted config URL. """ - # Check user preference first - pref_url = _get_preference_value('anthropic_api_url') + pref_url = _get_preference_value(pref_name) if pref_url: if validate_api_url(pref_url): return pref_url - # Preference URL not in allowlist — fall through to config + try: + from flask import current_app + current_app.logger.warning( + "LLM API URL preference '%s'=%r is not in " + "ALLOWED_LLM_API_URLS; ignoring. Add it to the " + "allowlist in config_local.py to permit this URL.", + pref_name, pref_url + ) + except Exception: + pass + return '' + return config_default or '' + - # Fall back to system configuration (trusted admin URL) - return config.ANTHROPIC_API_URL or '' +def is_pref_api_url_rejected(pref_name): + """ + Return True if the user has set a preference URL for ``pref_name`` + but it failed the allowlist check. Callers use this to distinguish + 'URL not configured' from 'URL configured but blocked' so the chat + path can surface a clear error instead of a generic one. + """ + pref_url = _get_preference_value(pref_name) + return bool(pref_url) and not validate_api_url(pref_url) -def get_anthropic_api_key(): +def _resolve_pref_key_file(pref_name, config_default): """ - Get the Anthropic API key. + Resolve an API key file preference against the path allowlist. - Checks user preferences first, then falls back to system configuration. + - User preference set and path allowed: read and return the key. + - User preference set but path rejected: log a warning and return + None WITHOUT falling back to the admin default. Silent + substitution would make the user's request go through using + a different key than they expected (the symptom in jbro90's + comment on issue #9936). + - No user preference: read from the admin's trusted config path. - Returns: - The API key string, or None if not configured or file doesn't exist. + Returns the key string, or None. """ - # Check user preference first - pref_file = _get_preference_value('anthropic_api_key_file') + pref_file = _get_preference_value(pref_name) if pref_file: - if validate_api_key_path(pref_file) is not None: - key = _read_api_key_from_file(pref_file) - if key: - return key - - # Fall back to system configuration (trusted admin path) - return _read_api_key_from_file( - config.ANTHROPIC_API_KEY_FILE, _trusted=True + safe_path = validate_api_key_path(pref_file) + if safe_path is None: + try: + from flask import current_app + current_app.logger.warning( + "LLM API key file preference '%s'=%r is not " + "within the allowed user storage directory; " + "ignoring. Place the key file in your private " + "user storage to use it.", + pref_name, pref_file + ) + except Exception: + pass + return None + return _read_api_key_from_file(safe_path) + return _read_api_key_from_file(config_default, _trusted=True) + + +def is_pref_api_key_path_rejected(pref_name): + """ + Return True if the user has set an API key file preference for + ``pref_name`` but the path failed the directory allowlist check. + + Used by client.py to distinguish 'no key configured' from 'key + path is rejected' when surfacing an error to the user. + """ + pref_file = _get_preference_value(pref_name) + return bool(pref_file) and validate_api_key_path(pref_file) is None + + +def get_anthropic_api_url(): + """ + Get the Anthropic API URL. + + Checks the user preference first, then falls back to system + configuration ONLY when no preference is set. A preference URL + that fails the SSRF allowlist check is dropped (and a warning is + logged) — it is NOT silently substituted with the admin default. + + Returns: + The URL string, or empty string if not configured or rejected. + """ + return _resolve_pref_url( + 'anthropic_api_url', config.ANTHROPIC_API_URL + ) + + +def get_anthropic_api_key(): + """ + Get the Anthropic API key. See :func:`_resolve_pref_key_file` + for resolution and rejection rules. + """ + return _resolve_pref_key_file( + 'anthropic_api_key_file', config.ANTHROPIC_API_KEY_FILE ) @@ -316,45 +418,19 @@ def get_anthropic_model(): def get_openai_api_url(): """ - Get the OpenAI API URL. - - Checks user preferences first, then falls back to system configuration. - User-preference URLs are validated against the SSRF allowlist. - - Returns: - The URL string, or empty string if not configured. + Get the OpenAI API URL. See :func:`get_anthropic_api_url` for + the resolution and rejection rules. """ - # Check user preference first - pref_url = _get_preference_value('openai_api_url') - if pref_url: - if validate_api_url(pref_url): - return pref_url - # Preference URL not in allowlist — fall through to config - - # Fall back to system configuration (trusted admin URL) - return config.OPENAI_API_URL or '' + return _resolve_pref_url('openai_api_url', config.OPENAI_API_URL) def get_openai_api_key(): """ - Get the OpenAI API key. - - Checks user preferences first, then falls back to system configuration. - - Returns: - The API key string, or None if not configured or file doesn't exist. + Get the OpenAI API key. See :func:`_resolve_pref_key_file` for + resolution and rejection rules. """ - # Check user preference first - pref_file = _get_preference_value('openai_api_key_file') - if pref_file: - if validate_api_key_path(pref_file) is not None: - key = _read_api_key_from_file(pref_file) - if key: - return key - - # Fall back to system configuration (trusted admin path) - return _read_api_key_from_file( - config.OPENAI_API_KEY_FILE, _trusted=True + return _resolve_pref_key_file( + 'openai_api_key_file', config.OPENAI_API_KEY_FILE ) @@ -378,23 +454,10 @@ def get_openai_model(): def get_ollama_api_url(): """ - Get the Ollama API URL. - - Checks user preferences first, then falls back to system configuration. - User-preference URLs are validated against the SSRF allowlist. - - Returns: - The URL string, or empty string if not configured. + Get the Ollama API URL. See :func:`get_anthropic_api_url` for + the resolution and rejection rules. """ - # Check user preference first - pref_url = _get_preference_value('ollama_api_url') - if pref_url: - if validate_api_url(pref_url): - return pref_url - # Preference URL not in allowlist — fall through to config - - # Fall back to system configuration (trusted admin URL) - return config.OLLAMA_API_URL or '' + return _resolve_pref_url('ollama_api_url', config.OLLAMA_API_URL) def get_ollama_model(): @@ -417,23 +480,10 @@ def get_ollama_model(): def get_docker_api_url(): """ - Get the Docker Model Runner API URL. - - Checks user preferences first, then falls back to system configuration. - User-preference URLs are validated against the SSRF allowlist. - - Returns: - The URL string, or empty string if not configured. + Get the Docker Model Runner API URL. See + :func:`get_anthropic_api_url` for resolution and rejection rules. """ - # Check user preference first - pref_url = _get_preference_value('docker_api_url') - if pref_url: - if validate_api_url(pref_url): - return pref_url - # Preference URL not in allowlist — fall through to config - - # Fall back to system configuration (trusted admin URL) - return config.DOCKER_API_URL or '' + return _resolve_pref_url('docker_api_url', config.DOCKER_API_URL) def get_docker_model():