From 3124b4923650987a072185801f3a5f287b631b4e Mon Sep 17 00:00:00 2001 From: Dave Page Date: Wed, 11 Mar 2026 10:49:07 +0000 Subject: [PATCH] Added support for custom LLM provider URLs for OpenAI and Anthropic, allowing use of OpenAI-compatible providers such as LM Studio, EXO, and LiteLLM. #9703 - Add configurable API URL fields for OpenAI and Anthropic providers - Make API keys optional when using custom URLs (for local providers) - Auto-clear model dropdown when provider settings change - Refresh button uses current unsaved form values - Update documentation and release notes Co-Authored-By: Claude Opus 4.6 --- docs/en_US/ai_tools.rst | 15 +- docs/en_US/preferences.rst | 27 ++- docs/en_US/release_notes_9_14.rst | 1 + web/config.py | 18 ++ web/pgadmin/llm/__init__.py | 156 ++++++++++++------ web/pgadmin/llm/client.py | 18 +- web/pgadmin/llm/providers/anthropic.py | 33 +++- web/pgadmin/llm/providers/openai.py | 33 +++- web/pgadmin/llm/utils.py | 38 +++++ .../js/components/PreferencesHelper.jsx | 29 ++++ .../static/js/SchemaView/MappedControl.jsx | 16 ++ .../static/js/components/FormComponents.jsx | 7 +- .../static/js/components/SelectRefresh.jsx | 59 +++++-- 13 files changed, 354 insertions(+), 96 deletions(-) diff --git a/docs/en_US/ai_tools.rst b/docs/en_US/ai_tools.rst index fb96a7e6351..1a81a0a9fa3 100644 --- a/docs/en_US/ai_tools.rst +++ b/docs/en_US/ai_tools.rst @@ -48,15 +48,18 @@ button and select *AI*). Select your preferred LLM provider from the dropdown: **Anthropic** - Use Claude models from Anthropic. Requires an Anthropic API key. + Use Claude models from Anthropic, or any Anthropic-compatible API provider. - * **API Key File**: Path to a file containing your Anthropic API key (obtain from https://console.anthropic.com/). + * **API URL**: Custom API endpoint URL (leave empty for default: https://api.anthropic.com/v1). + * **API Key File**: Path to a file containing your Anthropic API key (obtain from https://console.anthropic.com/). Optional when using a custom URL with a provider that does not require authentication. * **Model**: Select from available Claude models (e.g., claude-sonnet-4-20250514). **OpenAI** - Use GPT models from OpenAI. Requires an OpenAI API key. + Use GPT models from OpenAI, or any OpenAI-compatible API provider (e.g., + LiteLLM, LM Studio, EXO, or other local inference servers). - * **API Key File**: Path to a file containing your OpenAI API key (obtain from https://platform.openai.com/). + * **API URL**: Custom API endpoint URL (leave empty for default: https://api.openai.com/v1). Include the ``/v1`` path prefix if required by your provider. + * **API Key File**: Path to a file containing your OpenAI API key (obtain from https://platform.openai.com/). Optional when using a custom URL with a provider that does not require authentication. * **Model**: Select from available GPT models (e.g., gpt-4). **Ollama** @@ -72,6 +75,10 @@ Select your preferred LLM provider from the dropdown: * **API URL**: The URL of the Docker Model Runner API (default: http://localhost:12434). * **Model**: Select from available models or enter a custom model name. +.. note:: You can also use the *OpenAI* provider with a custom API URL for any + OpenAI-compatible endpoint, including Docker Model Runner and other local + inference servers. + After configuring your provider, click *Save* to apply the changes. diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index ff0adb947fd..caaaca4a268 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -47,19 +47,33 @@ Use the fields on the *AI* panel to configure your LLM provider: **Anthropic Settings:** +* Use the *API URL* field to set a custom API endpoint URL. Leave empty to use + the default Anthropic API (``https://api.anthropic.com/v1``). Set a custom URL + to use an Anthropic-compatible API provider. + * Use the *API Key File* field to specify the path to a file containing your - Anthropic API key. + Anthropic API key. The API key may be optional when using a custom API URL + with a provider that does not require authentication. * Use the *Model* field to select from the available Claude models. Click the - refresh button to fetch the latest available models from Anthropic. + refresh button to fetch the latest available models from your configured + endpoint. **OpenAI Settings:** +* Use the *API URL* field to set a custom API endpoint URL. Leave empty to use + the default OpenAI API (``https://api.openai.com/v1``). Set a custom URL to + use any OpenAI-compatible API provider (e.g., LiteLLM, LM Studio, EXO). + Include the ``/v1`` path prefix if required by your provider + (e.g., ``http://localhost:1234/v1``). + * Use the *API Key File* field to specify the path to a file containing your - OpenAI API key. + OpenAI API key. The API key may be optional when using a custom API URL + with a provider that does not require authentication. * Use the *Model* field to select from the available GPT models. Click the - refresh button to fetch the latest available models from OpenAI. + refresh button to fetch the latest available models from your configured + endpoint. **Ollama Settings:** @@ -79,6 +93,11 @@ Use the fields on the *AI* panel to configure your LLM provider: model name. Click the refresh button to fetch the latest available models from your Docker Model Runner. +.. note:: You can also use the *OpenAI* provider with a custom API URL for any + OpenAI-compatible endpoint, including Docker Model Runner, LM Studio, EXO, + and other local inference servers. This can be useful when you want to use + a provider that isn't explicitly listed but supports the OpenAI API format. + The Browser Node **************** diff --git a/docs/en_US/release_notes_9_14.rst b/docs/en_US/release_notes_9_14.rst index f841dcd5cd5..646dfa86450 100644 --- a/docs/en_US/release_notes_9_14.rst +++ b/docs/en_US/release_notes_9_14.rst @@ -21,6 +21,7 @@ New features ************ | `Issue #4011 `_ - Added support to download binary data from result grid. + | `Issue #9703 `_ - Added support for custom LLM provider URLs for OpenAI and Anthropic, allowing use of OpenAI-compatible providers such as LM Studio, EXO, and LiteLLM. Housekeeping ************ diff --git a/web/config.py b/web/config.py index 0786a5ed0ed..a814d75e6f1 100644 --- a/web/config.py +++ b/web/config.py @@ -987,9 +987,16 @@ DEFAULT_LLM_PROVIDER = '' # Anthropic Configuration +# URL for the Anthropic API endpoint. Leave empty to use the default +# (https://api.anthropic.com/v1). Set a custom URL to use an +# Anthropic-compatible API provider. +ANTHROPIC_API_URL = '' + # Path to a file containing the Anthropic API key. The file should contain # only the API key with no additional whitespace or formatting. # Default: ~/.anthropic-api-key +# Note: The API key may be optional when using a custom API URL with a +# provider that does not require authentication. ANTHROPIC_API_KEY_FILE = '~/.anthropic-api-key' # The Anthropic model to use for AI features. @@ -997,9 +1004,18 @@ ANTHROPIC_API_MODEL = '' # OpenAI Configuration +# URL for the OpenAI API endpoint. Leave empty to use the default +# (https://api.openai.com/v1). Set a custom URL to use any +# OpenAI-compatible API provider (e.g., LiteLLM, LM Studio, EXO). +# Include the /v1 path prefix if required by your provider +# (e.g., http://localhost:1234/v1). +OPENAI_API_URL = '' + # Path to a file containing the OpenAI API key. The file should contain # only the API key with no additional whitespace or formatting. # Default: ~/.openai-api-key +# Note: The API key may be optional when using a custom API URL with a +# provider that does not require authentication. OPENAI_API_KEY_FILE = '~/.openai-api-key' # The OpenAI model to use for AI features. @@ -1020,6 +1036,8 @@ # OpenAI-compatible API. No API key is required. # URL for the Docker Model Runner API endpoint. Leave empty to disable. # Typical value: http://localhost:12434 +# Tip: You can also use the OpenAI provider with a custom API URL for any +# OpenAI-compatible endpoint, including Docker Model Runner. DOCKER_API_URL = '' # The Docker Model Runner model to use for AI features. diff --git a/web/pgadmin/llm/__init__.py b/web/pgadmin/llm/__init__.py index 6bc2549750a..cbbdb4fcb60 100644 --- a/web/pgadmin/llm/__init__.py +++ b/web/pgadmin/llm/__init__.py @@ -94,11 +94,24 @@ def register_preferences(self): # Anthropic Settings # Get defaults from config + anthropic_url_default = getattr(config, 'ANTHROPIC_API_URL', '') anthropic_key_file_default = getattr( config, 'ANTHROPIC_API_KEY_FILE', '' ) anthropic_model_default = getattr(config, 'ANTHROPIC_API_MODEL', '') + self.anthropic_api_url = self.preference.register( + 'anthropic', 'anthropic_api_url', + gettext("API URL"), 'text', + anthropic_url_default, + category_label=gettext('Anthropic'), + help_str=gettext( + 'URL for the Anthropic API endpoint. Leave empty to use ' + 'the default (https://api.anthropic.com/v1). Set a custom ' + 'URL to use an Anthropic-compatible API provider.' + ) + ) + self.anthropic_api_key_file = self.preference.register( 'anthropic', 'anthropic_api_key_file', gettext("API Key File"), 'text', @@ -106,7 +119,9 @@ def register_preferences(self): category_label=gettext('Anthropic'), help_str=gettext( 'Path to a file containing your Anthropic API key. ' - 'The file should contain only the API key.' + 'The file should contain only the API key. The API key ' + 'may be optional when using a custom API URL with a ' + 'provider that does not require authentication.' ) ) @@ -132,6 +147,7 @@ def register_preferences(self): 'optionsUrl': 'llm.models_anthropic', 'optionsRefreshUrl': 'llm.refresh_models_anthropic', 'refreshDepNames': { + 'api_url': 'anthropic_api_url', 'api_key_file': 'anthropic_api_key_file' } } @@ -139,9 +155,25 @@ def register_preferences(self): # OpenAI Settings # Get defaults from config + openai_url_default = getattr(config, 'OPENAI_API_URL', '') openai_key_file_default = getattr(config, 'OPENAI_API_KEY_FILE', '') openai_model_default = getattr(config, 'OPENAI_API_MODEL', '') + self.openai_api_url = self.preference.register( + 'openai', 'openai_api_url', + gettext("API URL"), 'text', + openai_url_default, + category_label=gettext('OpenAI'), + help_str=gettext( + 'URL for the OpenAI API endpoint. Leave empty to use ' + 'the default (https://api.openai.com/v1). Set a custom ' + 'URL to use any OpenAI-compatible API provider such as ' + 'LiteLLM, LM Studio, or EXO. The URL should include the ' + '/v1 path prefix if required by your provider ' + '(e.g., http://localhost:1234/v1).' + ) + ) + self.openai_api_key_file = self.preference.register( 'openai', 'openai_api_key_file', gettext("API Key File"), 'text', @@ -149,7 +181,9 @@ def register_preferences(self): category_label=gettext('OpenAI'), help_str=gettext( 'Path to a file containing your OpenAI API key. ' - 'The file should contain only the API key.' + 'The file should contain only the API key. The API key ' + 'may be optional when using a custom API URL with a ' + 'provider that does not require authentication.' ) ) @@ -175,6 +209,7 @@ def register_preferences(self): 'optionsUrl': 'llm.models_openai', 'optionsRefreshUrl': 'llm.refresh_models_openai', 'refreshDepNames': { + 'api_url': 'openai_api_url', 'api_key_file': 'openai_api_key_file' } } @@ -236,7 +271,9 @@ def register_preferences(self): help_str=gettext( 'URL for the Docker Model Runner API endpoint ' '(e.g., http://localhost:12434). Available in Docker Desktop ' - '4.40 and later.' + '4.40 and later. Tip: You can also use the OpenAI provider ' + 'with a custom API URL for any OpenAI-compatible endpoint, ' + 'including Docker Model Runner.' ) ) @@ -353,17 +390,18 @@ def get_anthropic_models(): Fetch available Anthropic models. Returns models that support tool use. """ - from pgadmin.llm.utils import get_anthropic_api_key + from pgadmin.llm.utils import get_anthropic_api_key, get_anthropic_api_url api_key = get_anthropic_api_key() - if not api_key: + api_url = get_anthropic_api_url() + if not api_key and not api_url: return make_json_response( data={'models': [], 'error': 'No API key configured'}, status=200 ) try: - models = _fetch_anthropic_models(api_key) + models = _fetch_anthropic_models(api_key, api_url) return make_json_response(data={'models': models}, status=200) except Exception as e: return make_json_response( @@ -380,29 +418,29 @@ def get_anthropic_models(): @pga_login_required def refresh_anthropic_models(): """ - Fetch available Anthropic models using a provided API key file path. + Fetch available Anthropic models using a provided API key file path + and/or custom API URL. Used by the preferences refresh button to load models before saving. """ from pgadmin.llm.utils import read_api_key_file data = request.get_json(force=True, silent=True) or {} api_key_file = data.get('api_key_file', '') + api_url = data.get('api_url', '') - if not api_key_file: - return make_json_response( - data={'models': [], 'error': 'No API key file provided'}, - status=200 - ) + api_key = None + if api_key_file: + api_key = read_api_key_file(api_key_file) - api_key = read_api_key_file(api_key_file) - if not api_key: + if not api_key and not api_url: return make_json_response( - data={'models': [], 'error': 'Could not read API key from file'}, + data={'models': [], + 'error': 'No API key or custom URL provided'}, status=200 ) try: - models = _fetch_anthropic_models(api_key) + models = _fetch_anthropic_models(api_key, api_url) return make_json_response(data={'models': models}, status=200) except Exception as e: return make_json_response( @@ -418,17 +456,18 @@ def get_openai_models(): Fetch available OpenAI models. Returns models that support function calling. """ - from pgadmin.llm.utils import get_openai_api_key + from pgadmin.llm.utils import get_openai_api_key, get_openai_api_url api_key = get_openai_api_key() - if not api_key: + api_url = get_openai_api_url() + if not api_key and not api_url: return make_json_response( data={'models': [], 'error': 'No API key configured'}, status=200 ) try: - models = _fetch_openai_models(api_key) + models = _fetch_openai_models(api_key, api_url) return make_json_response(data={'models': models}, status=200) except Exception as e: return make_json_response( @@ -445,29 +484,28 @@ def get_openai_models(): @pga_login_required def refresh_openai_models(): """ - Fetch available OpenAI models using a provided API key file path. + Fetch available OpenAI models using a provided API key file path + and/or custom API URL. Used by the preferences refresh button to load models before saving. """ from pgadmin.llm.utils import read_api_key_file data = request.get_json(force=True, silent=True) or {} api_key_file = data.get('api_key_file', '') + api_url = data.get('api_url', '') - if not api_key_file: - return make_json_response( - data={'models': [], 'error': 'No API key file provided'}, - status=200 - ) + api_key = None + if api_key_file: + api_key = read_api_key_file(api_key_file) - api_key = read_api_key_file(api_key_file) - if not api_key: + if not api_key and not api_url: return make_json_response( - data={'models': [], 'error': 'Could not read API key from file'}, + data={'models': [], 'error': 'No API key or custom URL provided'}, status=200 ) try: - models = _fetch_openai_models(api_key) + models = _fetch_openai_models(api_key, api_url) return make_json_response(data={'models': models}, status=200) except Exception as e: return make_json_response( @@ -586,7 +624,7 @@ def refresh_docker_models(): ) -def _fetch_anthropic_models(api_key): +def _fetch_anthropic_models(api_key, api_url=''): """ Fetch models from Anthropic API. Returns a list of model options with label and value. @@ -594,13 +632,16 @@ def _fetch_anthropic_models(api_key): import urllib.request import urllib.error - req = urllib.request.Request( - 'https://api.anthropic.com/v1/models', - headers={ - 'x-api-key': api_key, - 'anthropic-version': '2023-06-01' - } - ) + base_url = (api_url or 'https://api.anthropic.com/v1').rstrip('/') + url = f'{base_url}/models' + + headers = { + 'anthropic-version': '2023-06-01' + } + if api_key: + headers['x-api-key'] = api_key + + req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen( @@ -611,6 +652,10 @@ def _fetch_anthropic_models(api_key): if e.code == 401: raise ValueError('Invalid API key') raise ConnectionError(f'API error: {e.code}') + except urllib.error.URLError as e: + raise ConnectionError( + f'Cannot connect to Anthropic API: {e.reason}' + ) models = [] seen = set() @@ -635,27 +680,35 @@ def _fetch_anthropic_models(api_key): 'value': model_id }) + if not models and api_url: + raise ConnectionError( + 'No models returned. Check that the API URL is correct.' + ) + # Sort alphabetically by model ID models.sort(key=lambda x: x['value']) return models -def _fetch_openai_models(api_key): +def _fetch_openai_models(api_key, api_url=''): """ - Fetch models from OpenAI API. + Fetch models from OpenAI API or any OpenAI-compatible endpoint. Returns a list of model options with label and value. """ import urllib.request import urllib.error - req = urllib.request.Request( - 'https://api.openai.com/v1/models', - headers={ - 'Authorization': f'Bearer {api_key}', - 'Content-Type': 'application/json' - } - ) + base_url = (api_url or 'https://api.openai.com/v1').rstrip('/') + url = f'{base_url}/models' + + headers = { + 'Content-Type': 'application/json' + } + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + + req = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen( @@ -666,6 +719,10 @@ def _fetch_openai_models(api_key): if e.code == 401: raise ValueError('Invalid API key') raise ConnectionError(f'API error: {e.code}') + except urllib.error.URLError as e: + raise ConnectionError( + f'Cannot connect to OpenAI API: {e.reason}' + ) models = [] seen = set() @@ -683,6 +740,13 @@ def _fetch_openai_models(api_key): 'value': model_id }) + if not models and api_url: + raise ConnectionError( + 'No models returned. Check that the API URL is correct ' + 'and includes the /v1 path prefix if required by your ' + 'provider (e.g., http://localhost:1234/v1).' + ) + # Sort alphabetically models.sort(key=lambda x: x['value']) diff --git a/web/pgadmin/llm/client.py b/web/pgadmin/llm/client.py index 5a4f114e6d7..3979bd6c1b4 100644 --- a/web/pgadmin/llm/client.py +++ b/web/pgadmin/llm/client.py @@ -128,8 +128,8 @@ def get_llm_client( """ from pgadmin.llm.utils import ( get_default_provider, - get_anthropic_api_key, get_anthropic_model, - get_openai_api_key, get_openai_model, + 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 ) @@ -145,24 +145,30 @@ def get_llm_client( if provider == 'anthropic': from pgadmin.llm.providers.anthropic import AnthropicClient api_key = get_anthropic_api_key() - if not api_key: + api_url = get_anthropic_api_url() + if not api_key and not api_url: raise LLMClientError(LLMError( message="Anthropic API key not configured", provider="anthropic" )) model_name = model or get_anthropic_model() - return AnthropicClient(api_key=api_key, model=model_name) + return AnthropicClient( + api_key=api_key, model=model_name, api_url=api_url + ) elif provider == 'openai': from pgadmin.llm.providers.openai import OpenAIClient api_key = get_openai_api_key() - if not api_key: + api_url = get_openai_api_url() + if not api_key and not api_url: raise LLMClientError(LLMError( message="OpenAI API key not configured", provider="openai" )) model_name = model or get_openai_model() - return OpenAIClient(api_key=api_key, model=model_name) + return OpenAIClient( + api_key=api_key, model=model_name, api_url=api_url + ) elif provider == 'ollama': from pgadmin.llm.providers.ollama import OllamaClient diff --git a/web/pgadmin/llm/providers/anthropic.py b/web/pgadmin/llm/providers/anthropic.py index d2e6d4af4bd..adba990f609 100644 --- a/web/pgadmin/llm/providers/anthropic.py +++ b/web/pgadmin/llm/providers/anthropic.py @@ -36,8 +36,8 @@ # Default model if none specified DEFAULT_MODEL = 'claude-sonnet-4-20250514' -# API configuration -API_URL = 'https://api.anthropic.com/v1/messages' +# Default API base URL +DEFAULT_API_BASE_URL = 'https://api.anthropic.com/v1' API_VERSION = '2023-06-01' @@ -45,19 +45,28 @@ class AnthropicClient(LLMClient): """ Anthropic Claude API client. - Implements the LLMClient interface for Anthropic's Claude models. + Implements the LLMClient interface for Anthropic's Claude models + and any Anthropic-compatible API endpoint. """ - def __init__(self, api_key: str, model: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, + model: Optional[str] = None, + api_url: Optional[str] = None): """ Initialize the Anthropic client. Args: - api_key: The Anthropic API key. + api_key: The Anthropic API key. Optional when using a custom + API URL with a provider that does not require + authentication. model: Optional model name. Defaults to claude-sonnet-4-20250514. + api_url: Optional custom API base URL. Defaults to + https://api.anthropic.com/v1. """ - self._api_key = api_key + self._api_key = api_key or '' self._model = model or DEFAULT_MODEL + base_url = (api_url or DEFAULT_API_BASE_URL).rstrip('/') + self._api_url = f'{base_url}/messages' @property def provider_name(self) -> str: @@ -69,7 +78,11 @@ def model_name(self) -> str: def is_available(self) -> bool: """Check if the client is properly configured.""" - return bool(self._api_key) + # API key is required for the default Anthropic endpoint, but optional + # for custom endpoints (e.g., local proxy servers). + if self._api_url.startswith(DEFAULT_API_BASE_URL): + return bool(self._api_key) + return True def chat( self, @@ -191,12 +204,14 @@ def _make_request(self, payload: dict) -> dict: """Make an HTTP request to the Anthropic API.""" headers = { 'Content-Type': 'application/json', - 'x-api-key': self._api_key, 'anthropic-version': API_VERSION } + if self._api_key: + headers['x-api-key'] = self._api_key + request = urllib.request.Request( - API_URL, + self._api_url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST' diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 3e7c169af1e..33268356785 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -37,27 +37,36 @@ # Default model if none specified DEFAULT_MODEL = 'gpt-4o' -# API configuration -API_URL = 'https://api.openai.com/v1/chat/completions' +# Default API base URL +DEFAULT_API_BASE_URL = 'https://api.openai.com/v1' class OpenAIClient(LLMClient): """ OpenAI GPT API client. - Implements the LLMClient interface for OpenAI's GPT models. + Implements the LLMClient interface for OpenAI's GPT models + and any OpenAI-compatible API endpoint. """ - def __init__(self, api_key: str, model: Optional[str] = None): + def __init__(self, api_key: Optional[str] = None, + model: Optional[str] = None, + api_url: Optional[str] = None): """ Initialize the OpenAI client. Args: - api_key: The OpenAI API key. + api_key: The OpenAI API key. Optional when using a custom + API URL with a provider that does not require + authentication. model: Optional model name. Defaults to gpt-4o. + api_url: Optional custom API base URL. Defaults to + https://api.openai.com/v1. """ - self._api_key = api_key + self._api_key = api_key or '' self._model = model or DEFAULT_MODEL + base_url = (api_url or DEFAULT_API_BASE_URL).rstrip('/') + self._api_url = f'{base_url}/chat/completions' @property def provider_name(self) -> str: @@ -69,7 +78,11 @@ def model_name(self) -> str: def is_available(self) -> bool: """Check if the client is properly configured.""" - return bool(self._api_key) + # API key is required for the default OpenAI endpoint, but optional + # for custom endpoints (e.g., local LLM servers). + if self._api_url.startswith(DEFAULT_API_BASE_URL): + return bool(self._api_key) + return True def chat( self, @@ -198,11 +211,13 @@ def _make_request(self, payload: dict) -> dict: """Make an HTTP request to the OpenAI API.""" headers = { 'Content-Type': 'application/json', - 'Authorization': f'Bearer {self._api_key}' } + if self._api_key: + headers['Authorization'] = f'Bearer {self._api_key}' + request = urllib.request.Request( - API_URL, + self._api_url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST' diff --git a/web/pgadmin/llm/utils.py b/web/pgadmin/llm/utils.py index a22a65ea8d4..8a2522a8a17 100644 --- a/web/pgadmin/llm/utils.py +++ b/web/pgadmin/llm/utils.py @@ -74,6 +74,24 @@ def _get_preference_value(name): return None +def get_anthropic_api_url(): + """ + Get the Anthropic API URL. + + Checks user preferences first, then falls back to system configuration. + + Returns: + The URL string, or empty string if not configured. + """ + # Check user preference first + pref_url = _get_preference_value('anthropic_api_url') + if pref_url: + return pref_url + + # Fall back to system configuration + return config.ANTHROPIC_API_URL or '' + + def get_anthropic_api_key(): """ Get the Anthropic API key. @@ -112,6 +130,24 @@ def get_anthropic_model(): return config.ANTHROPIC_API_MODEL or '' +def get_openai_api_url(): + """ + Get the OpenAI API URL. + + Checks user preferences first, then falls back to system configuration. + + Returns: + The URL string, or empty string if not configured. + """ + # Check user preference first + pref_url = _get_preference_value('openai_api_url') + if pref_url: + return pref_url + + # Fall back to system configuration + return config.OPENAI_API_URL or '' + + def get_openai_api_key(): """ Get the OpenAI API key. @@ -339,10 +375,12 @@ def get_llm_config(): 'default_provider': get_default_provider(), 'enabled': is_llm_enabled(), 'anthropic': { + 'api_url': get_anthropic_api_url(), 'api_key': get_anthropic_api_key(), 'model': get_anthropic_model() }, 'openai': { + 'api_url': get_openai_api_url(), 'api_key': get_openai_api_key(), 'model': get_openai_model() }, diff --git a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx index cd440bd7640..e8dcb1c78f4 100644 --- a/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx +++ b/web/pgadmin/preferences/static/js/components/PreferencesHelper.jsx @@ -127,6 +127,35 @@ export function prepareSubnodeData(node, subNode, nodeData, preferencesStore) { } element.controlProps.refreshDeps = refreshDeps; + // Register schema-level deps so the SchemaView subscriber + // mechanism triggers a re-render of this field when any + // dependency field changes (e.g., API URL or API key file). + const depIds = Object.values(refreshDeps); + if (depIds.length > 0) { + element.deps = depIds; + } + + // Set up blur-based clearing: when a dep field loses focus + // with a changed value, fire an event that SelectRefresh + // listens for to clear the model list. + const depChangeEmitter = new EventTarget(); + element.controlProps.depChangeEmitter = depChangeEmitter; + for (const prefName of Object.values(refreshDepNames)) { + const depPref = subNode.preferences.find((p) => p.name === prefName); + if (depPref) { + depPref.controlProps = depPref.controlProps || {}; + let focusValue = ''; + depPref.controlProps.onFocus = (e) => { + focusValue = e.target.value; + }; + depPref.controlProps.onBlur = (e) => { + if (e.target.value !== focusValue) { + depChangeEmitter.dispatchEvent(new Event('depchange')); + } + }; + } + } + // Also set up initial options loading via optionsUrl if (element.controlProps.optionsUrl) { const optionsEndpoint = element.controlProps.optionsUrl; diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index a2ec42295d2..78353a07097 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -384,6 +384,22 @@ export const MappedFormControl = ({ hasError, }; + // Pass current dependency values into controlProps so that + // child components (e.g., SelectRefresh) can read the live + // unsaved form values for dependent fields. + if (depVals && field.controlProps?.refreshDeps) { + const refreshDeps = field.controlProps.refreshDeps; + const depKeys = Object.keys(refreshDeps); + const currentDepValues = {}; + depKeys.forEach((paramName, idx) => { + currentDepValues[paramName] = depVals[idx]; + }); + newProps.controlProps = { + ...newProps.controlProps, + currentDepValues, + }; + } + if (typeof (field.type) === 'function') { const typeProps = evalFunc(null, field.type, state); newProps = { diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index 912ba3942d6..ba03e65aaaf 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -389,7 +389,6 @@ export function InputText({ref, cid, helpid, readonly, disabled, value, onChange onChange?.(changeVal); }; - const filteredProps = _.pickBy(props, (_v, key)=>( /* When used in ButtonGroup, following props should be skipped */ (!['color', 'disableElevation', 'disableFocusRipple', 'disableRipple'].includes(key)) @@ -421,6 +420,12 @@ export function InputText({ref, cid, helpid, readonly, disabled, value, onChange { ...(controlProps?.onKeyDown && { onKeyDown: controlProps.onKeyDown }) } + { + ...(controlProps?.onFocus && { onFocus: controlProps.onFocus }) + } + { + ...(controlProps?.onBlur && { onBlur: controlProps.onBlur }) + } {...controlProps} {...filteredProps} {...(['numeric', 'int'].indexOf(type) > -1 ? { type: 'tel' } : { type: type })} diff --git a/web/pgadmin/static/js/components/SelectRefresh.jsx b/web/pgadmin/static/js/components/SelectRefresh.jsx index 97bf72af886..b3ded1af836 100644 --- a/web/pgadmin/static/js/components/SelectRefresh.jsx +++ b/web/pgadmin/static/js/components/SelectRefresh.jsx @@ -7,7 +7,7 @@ // ////////////////////////////////////////////////////////////// -import { useState, useContext, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Box } from '@mui/material'; import { styled } from '@mui/material/styles'; import { InputSelect, FormInput } from './FormComponents'; @@ -18,7 +18,6 @@ import { PgIconButton } from './Buttons'; import getApiInstance from '../api_instance'; import url_for from 'sources/url_for'; import gettext from 'sources/gettext'; -import { SchemaStateContext } from '../SchemaView/SchemaState'; import { usePgAdmin } from '../PgAdminProvider'; import { clearOptionsCache } from '../../../preferences/static/js/components/PreferencesHelper'; @@ -62,33 +61,51 @@ ChildContent.propTypes = { isRefreshing: PropTypes.bool, }; -export function SelectRefresh({ required, className, label, helpMessage, testcid, controlProps, ...props }) { +export function SelectRefresh({ required, className, label, helpMessage, testcid, controlProps, options: fieldOptions, optionsReloadBasis: fieldReloadBasis, onChange, ...props }) { const [optionsState, setOptionsState] = useState({ options: [], reloadBasis: 0 }); const [isRefreshing, setIsRefreshing] = useState(false); - const schemaState = useContext(SchemaStateContext); const pgAdmin = usePgAdmin(); const { getOptionsOnRefresh, optionsRefreshUrl, optionsUrl, - refreshDeps, + refreshDeps: _refreshDeps, + currentDepValues, + depChangeEmitter, ...selectControlProps } = controlProps; + // Listen for blur-based changes on dependency fields. + // When a dep field (e.g., API URL, API key file) loses focus + // with a changed value, the emitter fires 'depchange' and we + // clear the cached options, model list, and selected value. + useEffect(() => { + if (!depChangeEmitter) return; + const handler = () => { + if (optionsUrl) { + clearOptionsCache(optionsUrl); + } + setOptionsState((prev) => ({ options: [], reloadBasis: prev.reloadBasis + 1 })); + onChange?.(''); + }; + depChangeEmitter.addEventListener('depchange', handler); + return () => depChangeEmitter.removeEventListener('depchange', handler); + }, [depChangeEmitter, optionsUrl, onChange]); + const onRefreshClick = useCallback(() => { // If we have an optionsRefreshUrl, make a POST request with dependent field values - if (optionsRefreshUrl && refreshDeps && schemaState) { + if (optionsRefreshUrl) { setIsRefreshing(true); - // Build the request body from dependent field values + // Build the request body from current dependency values. + // currentDepValues contains the live unsaved form values, + // keyed by param name (e.g., { api_url: '...', api_key_file: '...' }). const requestBody = {}; - for (const [paramName, fieldId] of Object.entries(refreshDeps)) { - // Find the field value from schema state - // fieldId is the preference ID, we need to look it up in state - const fieldValue = schemaState.data?.[fieldId]; - // Only include non-empty values - if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { - requestBody[paramName] = fieldValue; + if (currentDepValues) { + for (const [paramName, fieldValue] of Object.entries(currentDepValues)) { + if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { + requestBody[paramName] = fieldValue; + } } } @@ -138,17 +155,25 @@ export function SelectRefresh({ required, className, label, helpMessage, testcid setIsRefreshing(false); }); } - }, [optionsRefreshUrl, optionsUrl, refreshDeps, schemaState, getOptionsOnRefresh, pgAdmin]); + }, [optionsRefreshUrl, optionsUrl, currentDepValues, getOptionsOnRefresh, pgAdmin]); + + // Use field options (from GET endpoint) until the user refreshes + // or deps change, at which point optionsState takes over. + const activeOptions = optionsState.reloadBasis > 0 + ? optionsState.options : fieldOptions; + const activeReloadBasis = optionsState.reloadBasis > 0 + ? optionsState.reloadBasis : fieldReloadBasis; return ( );