Skip to content
9 changes: 7 additions & 2 deletions docs/en_US/ai_tools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Select your preferred LLM provider from the dropdown:

**OpenAI**
Use GPT models from OpenAI, or any OpenAI-compatible API provider (e.g.,
LiteLLM, LM Studio, EXO, or other local inference servers).
LiteLLM, LM Studio, EXO, GLM (Z.ai), or other local inference
servers).

* **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/). This path refers to the filesystem where the pgAdmin server is running (e.g., inside the container if using Docker). The ``~`` prefix is expanded to the home directory of the user running the pgAdmin server process. Optional when using a custom URL with a provider that does not require authentication.
Expand All @@ -77,7 +78,11 @@ Select your preferred LLM provider from the dropdown:

.. 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.
inference servers. pgAdmin automatically adapts to providers that expect
the legacy ``max_tokens`` parameter instead of
``max_completion_tokens``. When the ``ALLOWED_LLM_API_URLS`` configuration
setting is non-empty, custom API URLs must match one of its entries; an
empty list permits any URL.

After configuring your provider, click *Save* to apply the changes.

Expand Down
214 changes: 182 additions & 32 deletions web/pgadmin/llm/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import ssl
import urllib.request
import urllib.error
from urllib.parse import urlparse
from collections.abc import Generator
from typing import Optional, Union
import uuid
Expand All @@ -39,6 +40,13 @@
# Default model if none specified
DEFAULT_MODEL = 'gpt-4o'

# Cache of which token-limit parameter a given (base URL, model) pair
# accepts. OpenAI-compatible servers vary: newer OpenAI models expect
# 'max_completion_tokens' while many third-party servers still expect
# 'max_tokens'. Populated adaptively when a server rejects a parameter,
# so the fallback is remembered across new client instances.
_token_param_cache = {}

# Default API base URL
DEFAULT_API_BASE_URL = 'https://api.openai.com/v1'

Expand Down Expand Up @@ -85,6 +93,14 @@ def _api_url(self) -> str:
return f'{self._base_url}/responses'
return f'{self._base_url}/chat/completions'

@property
def _is_default_host(self) -> bool:
"""True when talking to the official OpenAI API host."""
# Compare hostnames, not authorities: an explicit default port
# (https://api.openai.com:443/v1) is still the official endpoint.
return (urlparse(self._base_url).hostname ==
urlparse(DEFAULT_API_BASE_URL).hostname)

@property
def provider_name(self) -> str:
return 'openai'
Expand All @@ -97,9 +113,7 @@ def is_available(self) -> bool:
"""Check if the client is properly configured."""
# API key is required for the default OpenAI endpoint, but optional
# for custom endpoints (e.g., local LLM servers).
if self._base_url.rstrip('/').startswith(
DEFAULT_API_BASE_URL.rstrip('/')
):
if self._is_default_host:
return bool(self._api_key)
return True

Expand Down Expand Up @@ -132,6 +146,34 @@ def chat(
messages, tools, system_prompt, max_tokens
)

# Reasoning models may consume the whole output budget on internal
# reasoning; retry once with a tripled budget.
if not kwargs.get('_reasoning_retried'):
try:
return self._chat_completions(
messages, tools, system_prompt, max_tokens
)
except LLMClientError as e:
if e.error.code == 'reasoning_budget':
kwargs['_reasoning_retried'] = True
return self.chat(
messages, tools, system_prompt,
max_tokens * 3, **kwargs
)
raise
return self._chat_completions(
messages, tools, system_prompt, max_tokens
)

def _chat_completions(
self,
messages: list[Message],
tools: Optional[list[Tool]],
system_prompt: Optional[str],
max_tokens: int
) -> LLMResponse:
"""Send a chat request using the Chat Completions API."""

# Try Chat Completions API first
payload = self._build_chat_payload(
messages, tools, system_prompt, max_tokens
Expand All @@ -141,6 +183,17 @@ def chat(
response_data = self._make_request(payload)
return self._parse_response(response_data)
except LLMClientError as e:
if self._is_unsupported_token_param_error(e):
# The server rejected 'max_completion_tokens'. Remember the
# fallback for this (server, model) pair and retry once
# with the legacy 'max_tokens' parameter.
_token_param_cache[(self._base_url, self._model)] = \
'max_tokens'
payload = self._build_chat_payload(
messages, tools, system_prompt, max_tokens
)
response_data = self._make_request(payload)
return self._parse_response(response_data)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if self._should_use_responses_api(e):
self._use_responses_api = True
return self._chat_responses(
Expand Down Expand Up @@ -178,11 +231,29 @@ def _chat_responses(

def _should_use_responses_api(self, error: LLMClientError) -> bool:
"""Check if the error indicates we should use the Responses API."""
# Only the official OpenAI API has a /responses endpoint to fall
# back to; never reroute requests for custom (OpenAI-compatible)
# servers.
if not self._is_default_host:
return False
error_msg = str(error).lower()
return ('v1/responses' in error_msg or
'not supported in the v1/chat/completions' in error_msg or
'not a chat model' in error_msg)

def _is_unsupported_token_param_error(
self, error: LLMClientError
) -> bool:
"""Check if the server rejected 'max_completion_tokens'."""
error_msg = str(error).lower()
return 'max_completion_tokens' in error_msg and (
'unsupported' in error_msg or
'unrecognized' in error_msg or
'unknown' in error_msg or
'not supported' in error_msg or
getattr(error.error, 'code', None) == 'unsupported_parameter'
)

def _build_chat_payload(
self,
messages: list[Message],
Expand All @@ -199,10 +270,13 @@ def _build_chat_payload(
'content': system_prompt
})

token_param = _token_param_cache.get(
(self._base_url, self._model), 'max_completion_tokens'
)
payload = {
'model': self._model,
'messages': converted_messages,
'max_completion_tokens': max_tokens,
token_param: max_tokens,
}

if tools:
Expand Down Expand Up @@ -357,6 +431,44 @@ def _convert_tools_responses(self, tools: list[Tool]) -> list[dict]:
for tool in tools
]

def _http_error_to_llm_error(
self, e: urllib.error.HTTPError
) -> LLMError:
"""Convert an HTTP error response to an LLMError.

Preserves the structured error details (code, parameter) so
callers can react to them - e.g. the unsupported-parameter
fallback - even when the message itself is generic.
"""
error_body = e.read().decode('utf-8')
error_msg = None
error_code = None
try:
error_data = json.loads(error_body)
except json.JSONDecodeError:
error_data = None
# Compatible servers do not all use OpenAI's error shape; only
# read the structured fields when they are actually mappings.
if isinstance(error_data, dict):
error_info = error_data.get('error')
if isinstance(error_info, dict):
error_msg = error_info.get('message')
if error_info.get('param'):
error_msg = (f"{error_msg or e!s} "
f"(parameter: '{error_info['param']}')")
error_code = error_info.get('code')
elif isinstance(error_info, str):
error_msg = error_info
if not error_msg:
error_msg = error_body or str(e)

return LLMError(
message=error_msg,
code=error_code or str(e.code),
provider=self.provider_name,
retryable=e.code in (429, 500, 502, 503, 504)
)

def _make_request(self, payload: dict) -> dict:
"""Make an HTTP request to the OpenAI API."""
headers = {
Expand All @@ -379,19 +491,7 @@ def _make_request(self, payload: dict) -> dict:
) as response:
return json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
try:
error_data = json.loads(error_body)
error_msg = error_data.get('error', {}).get('message', str(e))
except json.JSONDecodeError:
error_msg = error_body or str(e)

raise LLMClientError(LLMError(
message=error_msg,
code=str(e.code),
provider=self.provider_name,
retryable=e.code in (429, 500, 502, 503, 504)
))
raise LLMClientError(self._http_error_to_llm_error(e))
except urllib.error.URLError as e:
raise LLMClientError(LLMError(
message=f"Connection error: {e.reason}",
Expand Down Expand Up @@ -493,6 +593,22 @@ def _parse_response(self, data: dict) -> LLMResponse:
# Check for problematic responses
if not content and not tool_calls:
if stop_reason == StopReason.MAX_TOKENS:
details = usage_data.get('completion_tokens_details') or {}
reasoning_tokens = details.get('reasoning_tokens', 0)
if (message.get('reasoning_content') or
reasoning_tokens > 0):
# A reasoning model exhausted its output budget on
# internal reasoning before producing any visible
# content.
raise LLMClientError(LLMError(
message=f'Reasoning model {self._model} used its '
f'entire output budget on internal '
f'reasoning and returned no content. '
f'Increase the maximum tokens setting.',
code='reasoning_budget',
provider=self.provider_name,
retryable=True
))
self._raise_max_tokens_error(usage.input_tokens)
elif finish_reason and finish_reason not in ('stop', 'tool_calls'):
raise LLMClientError(LLMError(
Expand Down Expand Up @@ -634,7 +750,31 @@ def chat_stream(
try:
yield from self._process_stream(payload)
except LLMClientError as e:
if self._should_use_responses_api(e):
if (e.error.code == 'reasoning_budget' and
not kwargs.get('_reasoning_retried')):
# Retry once with a tripled budget. The error is raised
# after the stream ends without content, so nothing has
# been yielded to the caller yet.
kwargs['_reasoning_retried'] = True
yield from self.chat_stream(
messages, tools, system_prompt,
max_tokens * 3, temperature, **kwargs
)
elif self._is_unsupported_token_param_error(e):
# The server rejected 'max_completion_tokens'. Remember the
# fallback for this (server, model) pair and retry once
# with the legacy 'max_tokens' parameter. The rejection
# happens when the connection is opened, before any chunk
# has been yielded.
_token_param_cache[(self._base_url, self._model)] = \
'max_tokens'
payload = self._build_chat_payload(
messages, tools, system_prompt, max_tokens
)
payload['stream'] = True
payload['stream_options'] = {'include_usage': True}
yield from self._process_stream(payload)
elif self._should_use_responses_api(e):
self._use_responses_api = True
payload = self._build_responses_payload(
messages, tools, system_prompt, max_tokens
Expand Down Expand Up @@ -672,20 +812,7 @@ def _process_stream(
request, timeout=120, context=SSL_CONTEXT
)
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
try:
error_data = json.loads(error_body)
error_msg = error_data.get(
'error', {}
).get('message', str(e))
except json.JSONDecodeError:
error_msg = error_body or str(e)
raise LLMClientError(LLMError(
message=error_msg,
code=str(e.code),
provider=self.provider_name,
retryable=e.code in (429, 500, 502, 503, 504)
))
raise LLMClientError(self._http_error_to_llm_error(e))
except urllib.error.URLError as e:
raise LLMClientError(LLMError(
message=f"Connection error: {e.reason}",
Expand Down Expand Up @@ -723,6 +850,8 @@ def _read_openai_stream(
finish_reason = None
model_name = self._model
usage = Usage()
reasoning_seen = False
reasoning_tokens = 0

while True:
line_bytes = response.readline()
Expand Down Expand Up @@ -753,6 +882,8 @@ def _read_openai_stream(
output_tokens=u.get('completion_tokens', 0),
total_tokens=u.get('total_tokens', 0)
)
details = u.get('completion_tokens_details') or {}
reasoning_tokens = details.get('reasoning_tokens', 0)

if 'model' in data:
model_name = data['model']
Expand All @@ -767,6 +898,11 @@ def _read_openai_stream(
if choice.get('finish_reason'):
finish_reason = choice['finish_reason']

# Reasoning content is tracked (for budget-exhaustion
# detection) but not yielded as visible content.
if delta.get('reasoning_content'):
reasoning_seen = True

# Text content
text_chunk = delta.get('content')
if text_chunk:
Expand Down Expand Up @@ -820,6 +956,20 @@ def _read_openai_stream(
)

if not content and not tool_calls:
if finish_reason == 'length' and (
reasoning_seen or reasoning_tokens > 0
):
# A reasoning model exhausted its output budget on
# internal reasoning before producing any content.
raise LLMClientError(LLMError(
message=f'Reasoning model {self._model} used its '
f'entire output budget on internal '
f'reasoning and returned no content. '
f'Increase the maximum tokens setting.',
code='reasoning_budget',
provider=self.provider_name,
retryable=True
))
raise LLMClientError(LLMError(
message='No response content returned from API',
provider=self.provider_name,
Expand Down
Loading