mirror of
https://github.com/pgadmin-org/pgadmin4.git
synced 2026-08-17 16:34:44 -05:00
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:
co-authored by
Claude Opus 4.6
parent
78e3b67eb1
commit
b3aa78c0a5
+11
-4
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
****************
|
||||
|
||||
|
||||
@@ -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
|
||||
************
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user