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 <noreply@anthropic.com>
This commit is contained in:
Dave Page
2026-03-16 12:08:55 +05:30
committed by GitHub
co-authored by Claude Opus 4.6
parent 78e3b67eb1
commit b3aa78c0a5
13 changed files with 354 additions and 96 deletions
+11 -4
View File
@@ -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.
+23 -4
View File
@@ -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
****************
+1
View File
@@ -21,6 +21,7 @@ New features
************
| `Issue #4011 <https://github.com/pgadmin-org/pgadmin4/issues/4011>`_ - Added support to download binary data from result grid.
| `Issue #9703 <https://github.com/pgadmin-org/pgadmin4/issues/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
************
+18
View File
@@ -987,9 +987,16 @@ LLM_ENABLED = True
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_KEY_FILE = '~/.anthropic-api-key'
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 @@ OLLAMA_API_MODEL = ''
# 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.
+110 -46
View File
@@ -94,11 +94,24 @@ class LLMModule(PgAdminModule):
# 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 @@ class LLMModule(PgAdminModule):
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 @@ class LLMModule(PgAdminModule):
'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 @@ class LLMModule(PgAdminModule):
# 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 @@ class LLMModule(PgAdminModule):
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 @@ class LLMModule(PgAdminModule):
'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 @@ class LLMModule(PgAdminModule):
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'])
+12 -6
View File
@@ -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
+24 -9
View File
@@ -36,8 +36,8 @@ from pgadmin.llm.models import (
# 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 @@ class AnthropicClient(LLMClient):
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 @@ class AnthropicClient(LLMClient):
"""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'
+24 -9
View File
@@ -37,27 +37,36 @@ from pgadmin.llm.models import (
# 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 @@ class OpenAIClient(LLMClient):
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 @@ class OpenAIClient(LLMClient):
"""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'
+38
View File
@@ -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()
},
@@ -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;
@@ -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 = {
@@ -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 })}
@@ -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 (
<FormInput required={required} label={label} className={className} helpMessage={helpMessage} testcid={testcid}>
<ChildContent
options={optionsState.options}
optionsReloadBasis={optionsState.reloadBasis}
onRefreshClick={onRefreshClick}
controlProps={selectControlProps}
isRefreshing={isRefreshing}
onChange={onChange}
{...props}
options={activeOptions}
optionsReloadBasis={activeReloadBasis}
/>
</FormInput>
);