From 0632dc398094ba4d03475134e97c9a956e7472ba Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:41:52 +0530 Subject: [PATCH 1/9] Make the OpenAI LLM provider adapt to OpenAI-compatible servers and reasoning models. #10570 --- docs/en_US/ai_tools.rst | 8 +- web/pgadmin/llm/providers/openai.py | 92 +++++++++++- web/pgadmin/llm/tests/test_openai_stream.py | 150 ++++++++++++++++++++ 3 files changed, 244 insertions(+), 6 deletions(-) diff --git a/docs/en_US/ai_tools.rst b/docs/en_US/ai_tools.rst index 68416bb46ba..977a8f06dd3 100644 --- a/docs/en_US/ai_tools.rst +++ b/docs/en_US/ai_tools.rst @@ -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. @@ -77,7 +78,10 @@ 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``. Custom API URLs must also be listed in the + ``ALLOWED_LLM_API_URLS`` configuration setting. After configuring your provider, click *Save* to apply the changes. diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 075652e3224..4e44b1897fa 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -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 @@ -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' @@ -85,6 +93,12 @@ 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.""" + return (urlparse(self._base_url).netloc.lower() == + urlparse(DEFAULT_API_BASE_URL).netloc.lower()) + @property def provider_name(self) -> str: return 'openai' @@ -97,9 +111,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 @@ -132,6 +144,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 @@ -141,6 +181,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) if self._should_use_responses_api(e): self._use_responses_api = True return self._chat_responses( @@ -178,11 +229,28 @@ 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 + ) + def _build_chat_payload( self, messages: list[Message], @@ -199,10 +267,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: @@ -493,6 +564,19 @@ def _parse_response(self, data: dict) -> LLMResponse: # Check for problematic responses if not content and not tool_calls: if stop_reason == StopReason.MAX_TOKENS: + if message.get('reasoning_content'): + # 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( diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 59ffd27480d..22aa40a4f20 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -278,3 +278,153 @@ def runTest(self): actual = [(tc.id, tc.name, tc.arguments) for tc in result.tool_calls] self.assertEqual(actual, self.expected) + + +class OpenAICompatibleServerTestCase(BaseTestGenerator): + """Adaptive behaviour for OpenAI-compatible servers and reasoning models. + + Each scenario drives chat() with _make_request replaced by a script of + canned responses/errors, and asserts on the requests that would have + been sent. No network access is involved. + """ + + scenarios = [ + ('Unsupported max_completion_tokens falls back to max_tokens', dict( + kind='token_fallback', + api_url='http://localhost:1234/v1', + model='glm-4', + )), + ('Token parameter fallback is remembered for new clients', dict( + kind='token_cache', + api_url='http://localhost:1234/v1', + model='glm-4', + )), + ('Custom host is never rerouted to the Responses API', dict( + kind='no_reroute', + api_url='http://localhost:1234/v1', + model='glm-4', + )), + ('Reasoning exhaustion retries with a tripled budget', dict( + kind='reasoning_retry', + api_url='http://localhost:1234/v1', + model='deepseek-r1', + )), + ('Lookalike hostname is treated as a custom provider', dict( + kind='host_check', + api_url='https://api.openai.com.evil.example/v1', + model='gpt-4o', + )), + ] + + def runTest(self): + from pgadmin.llm.providers import openai as openai_module + from pgadmin.llm.client import LLMClientError + from pgadmin.llm.models import LLMError, Message + + openai_module._token_param_cache.clear() + + def make_client(): + return OpenAIClient( + api_key='test-key', model=self.model, api_url=self.api_url) + + def ok_response(content='hello'): + return { + 'choices': [{'message': {'content': content}, + 'finish_reason': 'stop'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, + 'total_tokens': 2}, + 'model': self.model, + } + + if self.kind == 'token_fallback': + client = make_client() + sent_params = [] + + def fake_request(payload): + sent_params.append( + 'max_completion_tokens' + if 'max_completion_tokens' in payload else 'max_tokens') + if sent_params[-1] == 'max_completion_tokens': + raise LLMClientError(LLMError( + message="Unsupported parameter: " + "'max_completion_tokens' is not supported " + "with this model.", + code='unsupported_parameter', + provider='openai')) + return ok_response() + + client._make_request = fake_request + result = client.chat([Message(role='user', content='hi')]) + self.assertEqual( + sent_params, ['max_completion_tokens', 'max_tokens']) + self.assertEqual(result.content, 'hello') + + elif self.kind == 'token_cache': + # A new client for the same (server, model) pair must use the + # remembered parameter immediately, without another rejection. + openai_module._token_param_cache[ + ('http://localhost:1234/v1', 'glm-4')] = 'max_tokens' + client = make_client() + sent_params = [] + + def fake_request(payload): + sent_params.append( + 'max_completion_tokens' + if 'max_completion_tokens' in payload else 'max_tokens') + return ok_response() + + client._make_request = fake_request + result = client.chat([Message(role='user', content='hi')]) + self.assertEqual(sent_params, ['max_tokens']) + self.assertEqual(result.content, 'hello') + + elif self.kind == 'no_reroute': + client = make_client() + urls = [] + + def fake_request(payload): + urls.append(client._api_url) + raise LLMClientError(LLMError( + message='This model requires the v1/responses endpoint', + code='bad_request', provider='openai')) + + client._make_request = fake_request + with self.assertRaises(LLMClientError): + client.chat([Message(role='user', content='hi')]) + self.assertEqual( + urls, ['http://localhost:1234/v1/chat/completions']) + + elif self.kind == 'reasoning_retry': + client = make_client() + budgets = [] + + def fake_request(payload): + budgets.append( + payload.get('max_completion_tokens') or + payload.get('max_tokens')) + if len(budgets) == 1: + return { + 'choices': [{'message': { + 'content': '', + 'reasoning_content': 'thinking...'}, + 'finish_reason': 'length'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 100, + 'total_tokens': 101}, + 'model': self.model, + } + return ok_response('done') + + client._make_request = fake_request + result = client.chat( + [Message(role='user', content='hi')], max_tokens=1000) + self.assertEqual(budgets, [1000, 3000]) + self.assertEqual(result.content, 'done') + + elif self.kind == 'host_check': + # A hostname merely starting with api.openai.com must not be + # treated as the official endpoint. + self.assertTrue(make_client().is_available()) + self.assertFalse( + OpenAIClient(api_key=None, model='gpt-4o').is_available()) + + openai_module._token_param_cache.clear() From 4d52a2287c7e1ba3f86bf038faa4a3c6e47339df Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:26:52 +0530 Subject: [PATCH 2/9] Apply the token-parameter fallback to chat_stream() too, and clarify the empty-allowlist behaviour in the AI tools documentation. #10570 --- docs/en_US/ai_tools.rst | 5 ++-- web/pgadmin/llm/providers/openai.py | 16 ++++++++++- web/pgadmin/llm/tests/test_openai_stream.py | 30 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/en_US/ai_tools.rst b/docs/en_US/ai_tools.rst index 977a8f06dd3..866f36f2831 100644 --- a/docs/en_US/ai_tools.rst +++ b/docs/en_US/ai_tools.rst @@ -80,8 +80,9 @@ Select your preferred LLM provider from the dropdown: OpenAI-compatible endpoint, including Docker Model Runner and other local inference servers. pgAdmin automatically adapts to providers that expect the legacy ``max_tokens`` parameter instead of - ``max_completion_tokens``. Custom API URLs must also be listed in the - ``ALLOWED_LLM_API_URLS`` configuration setting. + ``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. diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 4e44b1897fa..bafeaee1c65 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -718,7 +718,21 @@ def chat_stream( try: yield from self._process_stream(payload) except LLMClientError as e: - if self._should_use_responses_api(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. 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 diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 22aa40a4f20..31d9574c5a1 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -314,6 +314,11 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): api_url='https://api.openai.com.evil.example/v1', model='gpt-4o', )), + ('Streaming falls back to max_tokens on unsupported parameter', dict( + kind='stream_token_fallback', + api_url='http://localhost:1234/v1', + model='glm-4', + )), ] def runTest(self): @@ -420,6 +425,31 @@ def fake_request(payload): self.assertEqual(budgets, [1000, 3000]) self.assertEqual(result.content, 'done') + elif self.kind == 'stream_token_fallback': + client = make_client() + sent_params = [] + + def fake_process_stream(payload): + sent_params.append( + 'max_completion_tokens' + if 'max_completion_tokens' in payload else 'max_tokens') + self.assertTrue(payload.get('stream')) + if sent_params[-1] == 'max_completion_tokens': + raise LLMClientError(LLMError( + message="Unsupported parameter: " + "'max_completion_tokens' is not supported " + "with this model.", + code='unsupported_parameter', + provider='openai')) + yield 'chunk' + + client._process_stream = fake_process_stream + chunks = list(client.chat_stream( + [Message(role='user', content='hi')])) + self.assertEqual( + sent_params, ['max_completion_tokens', 'max_tokens']) + self.assertEqual(chunks, ['chunk']) + elif self.kind == 'host_check': # A hostname merely starting with api.openai.com must not be # treated as the official endpoint. From 3e7cc8d1839a3e44d25a9dcb6f2e529bf3d7e1b4 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:02:36 +0530 Subject: [PATCH 3/9] Preserve structured error details so the token-parameter fallback also triggers on generic error messages, and test that the reasoning-budget retry happens only once. #10570 --- web/pgadmin/llm/providers/openai.py | 16 +++++- web/pgadmin/llm/tests/test_openai_stream.py | 62 +++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index bafeaee1c65..cab97bd7d47 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -248,7 +248,8 @@ def _is_unsupported_token_param_error( 'unsupported' in error_msg or 'unrecognized' in error_msg or 'unknown' in error_msg or - 'not supported' in error_msg + 'not supported' in error_msg or + getattr(error.error, 'code', None) == 'unsupported_parameter' ) def _build_chat_payload( @@ -453,13 +454,22 @@ def _make_request(self, payload: dict) -> dict: error_body = e.read().decode('utf-8') try: error_data = json.loads(error_body) - error_msg = error_data.get('error', {}).get('message', str(e)) + error_info = error_data.get('error', {}) + error_msg = error_info.get('message', str(e)) + # Preserve the structured error details so callers can + # react to them (e.g. the unsupported-parameter fallback) + # even when the message itself is generic. + if error_info.get('param'): + error_msg = (f"{error_msg} " + f"(parameter: '{error_info['param']}')") + error_code = error_info.get('code') or str(e.code) except json.JSONDecodeError: error_msg = error_body or str(e) + error_code = str(e.code) raise LLMClientError(LLMError( message=error_msg, - code=str(e.code), + code=error_code, provider=self.provider_name, retryable=e.code in (429, 500, 502, 503, 504) )) diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 31d9574c5a1..51b12a37e8e 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -319,6 +319,16 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): api_url='http://localhost:1234/v1', model='glm-4', )), + ('Structured error fields still trigger the fallback', dict( + kind='structured_token_fallback', + api_url='http://localhost:1234/v1', + model='glm-4', + )), + ('Reasoning budget is retried only once', dict( + kind='reasoning_retry_once', + api_url='http://localhost:1234/v1', + model='deepseek-r1', + )), ] def runTest(self): @@ -450,6 +460,58 @@ def fake_process_stream(payload): sent_params, ['max_completion_tokens', 'max_tokens']) self.assertEqual(chunks, ['chunk']) + elif self.kind == 'structured_token_fallback': + # A server reporting the rejection only through structured + # error fields (generic message, code + parameter) must still + # trigger the fallback. + client = make_client() + sent_params = [] + + def fake_request(payload): + sent_params.append( + 'max_completion_tokens' + if 'max_completion_tokens' in payload else 'max_tokens') + if sent_params[-1] == 'max_completion_tokens': + raise LLMClientError(LLMError( + message="Invalid request (parameter: " + "'max_completion_tokens')", + code='unsupported_parameter', + provider='openai')) + return ok_response() + + client._make_request = fake_request + result = client.chat([Message(role='user', content='hi')]) + self.assertEqual( + sent_params, ['max_completion_tokens', 'max_tokens']) + self.assertEqual(result.content, 'hello') + + elif self.kind == 'reasoning_retry_once': + # The tripled-budget retry happens once; a second exhausted + # response surfaces the budget error to the caller. + client = make_client() + budgets = [] + + def fake_request(payload): + budgets.append( + payload.get('max_completion_tokens') or + payload.get('max_tokens')) + return { + 'choices': [{'message': { + 'content': '', + 'reasoning_content': 'thinking...'}, + 'finish_reason': 'length'}], + 'usage': {'prompt_tokens': 1, 'completion_tokens': 100, + 'total_tokens': 101}, + 'model': self.model, + } + + client._make_request = fake_request + with self.assertRaises(LLMClientError) as ctx: + client.chat( + [Message(role='user', content='hi')], max_tokens=1000) + self.assertEqual(budgets, [1000, 3000]) + self.assertEqual(ctx.exception.error.code, 'reasoning_budget') + elif self.kind == 'host_check': # A hostname merely starting with api.openai.com must not be # treated as the official endpoint. From ce93278fce20d5817cabe302e4336760d1f74c0f Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:44:04 +0530 Subject: [PATCH 4/9] Share structured HTTP error parsing between the streaming and non-streaming request paths. #10570 --- web/pgadmin/llm/providers/openai.py | 67 ++++++++++----------- web/pgadmin/llm/tests/test_openai_stream.py | 50 +++++++++++++++ 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index cab97bd7d47..86941681157 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -429,6 +429,35 @@ 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') + try: + error_data = json.loads(error_body) + error_info = error_data.get('error', {}) + error_msg = error_info.get('message', str(e)) + if error_info.get('param'): + error_msg = (f"{error_msg} " + f"(parameter: '{error_info['param']}')") + error_code = error_info.get('code') or str(e.code) + except json.JSONDecodeError: + error_msg = error_body or str(e) + error_code = str(e.code) + + return LLMError( + message=error_msg, + code=error_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 = { @@ -451,28 +480,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_info = error_data.get('error', {}) - error_msg = error_info.get('message', str(e)) - # Preserve the structured error details so callers can - # react to them (e.g. the unsupported-parameter fallback) - # even when the message itself is generic. - if error_info.get('param'): - error_msg = (f"{error_msg} " - f"(parameter: '{error_info['param']}')") - error_code = error_info.get('code') or str(e.code) - except json.JSONDecodeError: - error_msg = error_body or str(e) - error_code = str(e.code) - - raise LLMClientError(LLMError( - message=error_msg, - code=error_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}", @@ -780,20 +788,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}", diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 51b12a37e8e..7cc745effbc 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -329,6 +329,11 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): api_url='http://localhost:1234/v1', model='deepseek-r1', )), + ('Streaming falls back on structured error fields', dict( + kind='stream_structured_fallback', + api_url='http://localhost:1234/v1', + model='glm-4', + )), ] def runTest(self): @@ -512,6 +517,51 @@ def fake_request(payload): self.assertEqual(budgets, [1000, 3000]) self.assertEqual(ctx.exception.error.code, 'reasoning_budget') + elif self.kind == 'stream_structured_fallback': + # Exercises the real _process_stream HTTP error branch: a + # streaming server reporting the rejection only through + # structured error fields must still trigger the fallback. + import io + import urllib.error + + client = make_client() + calls = [] + body = json.dumps({'error': { + 'message': 'Invalid request', + 'code': 'unsupported_parameter', + 'param': 'max_completion_tokens'}}).encode('utf-8') + + def fake_urlopen(request, timeout=None, context=None): + payload = json.loads(request.data.decode('utf-8')) + calls.append( + 'max_completion_tokens' + if 'max_completion_tokens' in payload else 'max_tokens') + if calls[-1] == 'max_completion_tokens': + raise urllib.error.HTTPError( + request.full_url, 400, 'Bad Request', {}, + io.BytesIO(body)) + stream = _FakeStream([ + _sse({'choices': [{'index': 0, 'delta': { + 'role': 'assistant', 'content': 'hi'}}]}), + _sse({'choices': [{'index': 0, + 'finish_reason': 'stop', + 'delta': {}}]}), + 'data: [DONE]', + ]) + stream.close = lambda: None + return stream + + original = openai_module.urlopen_no_redirect + openai_module.urlopen_no_redirect = fake_urlopen + try: + chunks = list(client.chat_stream( + [Message(role='user', content='hi')])) + finally: + openai_module.urlopen_no_redirect = original + self.assertEqual( + calls, ['max_completion_tokens', 'max_tokens']) + self.assertIn('hi', chunks) + elif self.kind == 'host_check': # A hostname merely starting with api.openai.com must not be # treated as the official endpoint. From 27b464248b12082b1b77729149a31091bce5e7b8 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:14:53 +0530 Subject: [PATCH 5/9] Detect reasoning-budget exhaustion from usage details, apply the budget retry to streaming, and tolerate non-OpenAI error shapes. #10570 --- web/pgadmin/llm/providers/openai.py | 65 +++++++++-- web/pgadmin/llm/tests/test_openai_stream.py | 120 ++++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 86941681157..9357dda2071 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -439,21 +439,30 @@ def _http_error_to_llm_error( 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) - error_info = error_data.get('error', {}) - error_msg = error_info.get('message', str(e)) - if error_info.get('param'): - error_msg = (f"{error_msg} " - f"(parameter: '{error_info['param']}')") - error_code = error_info.get('code') or str(e.code) 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) - error_code = str(e.code) return LLMError( message=error_msg, - code=error_code, + code=error_code or str(e.code), provider=self.provider_name, retryable=e.code in (429, 500, 502, 503, 504) ) @@ -582,7 +591,10 @@ def _parse_response(self, data: dict) -> LLMResponse: # Check for problematic responses if not content and not tool_calls: if stop_reason == StopReason.MAX_TOKENS: - if message.get('reasoning_content'): + 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. @@ -736,7 +748,17 @@ def chat_stream( try: yield from self._process_stream(payload) except LLMClientError as e: - if self._is_unsupported_token_param_error(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 @@ -826,6 +848,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() @@ -856,6 +880,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'] @@ -870,6 +896,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: @@ -923,6 +954,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, diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 7cc745effbc..ac3ac460abc 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -334,6 +334,21 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): api_url='http://localhost:1234/v1', model='glm-4', )), + ('Malformed error bodies still produce a usable error', dict( + kind='malformed_error_body', + api_url='http://localhost:1234/v1', + model='glm-4', + )), + ('Reasoning exhaustion detected from usage details', dict( + kind='reasoning_from_usage', + api_url='http://localhost:1234/v1', + model='o3-mini', + )), + ('Streaming reasoning exhaustion retries with tripled budget', dict( + kind='stream_reasoning_retry', + api_url='http://localhost:1234/v1', + model='deepseek-r1', + )), ] def runTest(self): @@ -562,6 +577,111 @@ def fake_urlopen(request, timeout=None, context=None): calls, ['max_completion_tokens', 'max_tokens']) self.assertIn('hi', chunks) + elif self.kind == 'malformed_error_body': + import io + import urllib.error + + client = make_client() + + def http_error(body): + return urllib.error.HTTPError( + 'http://localhost:1234/v1/chat/completions', + 400, 'Bad Request', {}, + io.BytesIO(body.encode('utf-8'))) + + # error as a plain string + err = client._http_error_to_llm_error( + http_error('{"error": "invalid model"}')) + self.assertEqual(err.message, 'invalid model') + self.assertEqual(err.code, '400') + + # JSON null body + err = client._http_error_to_llm_error(http_error('null')) + self.assertEqual(err.message, 'null') + + # non-JSON body + err = client._http_error_to_llm_error(http_error('oops')) + self.assertEqual(err.message, 'oops') + + elif self.kind == 'reasoning_from_usage': + # No reasoning_content in the message; the reasoning spend is + # only visible in usage.completion_tokens_details. + client = make_client() + budgets = [] + + def fake_request(payload): + budgets.append( + payload.get('max_completion_tokens') or + payload.get('max_tokens')) + if len(budgets) == 1: + return { + 'choices': [{'message': {'content': ''}, + 'finish_reason': 'length'}], + 'usage': { + 'prompt_tokens': 1, 'completion_tokens': 100, + 'total_tokens': 101, + 'completion_tokens_details': { + 'reasoning_tokens': 100}}, + 'model': self.model, + } + return ok_response('done') + + client._make_request = fake_request + result = client.chat( + [Message(role='user', content='hi')], max_tokens=1000) + self.assertEqual(budgets, [1000, 3000]) + self.assertEqual(result.content, 'done') + + elif self.kind == 'stream_reasoning_retry': + import io + import urllib.error # noqa: F401 + + client = make_client() + budgets = [] + + exhausted_stream = _FakeStream([ + _sse({'choices': [{'index': 0, 'delta': { + 'role': 'assistant', + 'reasoning_content': 'thinking...'}}]}), + _sse({'choices': [{'index': 0, + 'finish_reason': 'length', + 'delta': {}}], + 'usage': { + 'prompt_tokens': 1, 'completion_tokens': 100, + 'total_tokens': 101, + 'completion_tokens_details': { + 'reasoning_tokens': 100}}}), + 'data: [DONE]', + ]) + ok_stream = _FakeStream([ + _sse({'choices': [{'index': 0, 'delta': { + 'role': 'assistant', 'content': 'hi'}}]}), + _sse({'choices': [{'index': 0, + 'finish_reason': 'stop', + 'delta': {}}]}), + 'data: [DONE]', + ]) + for s in (exhausted_stream, ok_stream): + s.close = lambda: None + streams = [exhausted_stream, ok_stream] + + def fake_urlopen(request, timeout=None, context=None): + payload = json.loads(request.data.decode('utf-8')) + budgets.append( + payload.get('max_completion_tokens') or + payload.get('max_tokens')) + return streams[len(budgets) - 1] + + original = openai_module.urlopen_no_redirect + openai_module.urlopen_no_redirect = fake_urlopen + try: + chunks = list(client.chat_stream( + [Message(role='user', content='hi')], max_tokens=1000)) + finally: + openai_module.urlopen_no_redirect = original + self.assertEqual(budgets, [1000, 3000]) + self.assertIn('hi', chunks) + elif self.kind == 'host_check': # A hostname merely starting with api.openai.com must not be # treated as the official endpoint. From ab4a20cf122855253bfa1a6e72cd6492934cec48 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:11:24 +0530 Subject: [PATCH 6/9] Compare hostnames rather than URL authorities when identifying the official OpenAI endpoint. #10570 --- web/pgadmin/llm/providers/openai.py | 6 ++++-- web/pgadmin/llm/tests/test_openai_stream.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 9357dda2071..3a540a96cf2 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -96,8 +96,10 @@ def _api_url(self) -> str: @property def _is_default_host(self) -> bool: """True when talking to the official OpenAI API host.""" - return (urlparse(self._base_url).netloc.lower() == - urlparse(DEFAULT_API_BASE_URL).netloc.lower()) + # 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: diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index ac3ac460abc..d5a66081e45 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -349,6 +349,11 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): api_url='http://localhost:1234/v1', model='deepseek-r1', )), + ('Default host with explicit port is still the official API', dict( + kind='explicit_port_host', + api_url='https://api.openai.com:443/v1', + model='gpt-4o', + )), ] def runTest(self): @@ -682,6 +687,19 @@ def fake_urlopen(request, timeout=None, context=None): self.assertEqual(budgets, [1000, 3000]) self.assertIn('hi', chunks) + elif self.kind == 'explicit_port_host': + # An explicit default port must not make the official endpoint + # look like a custom provider. + self.assertTrue(make_client()._is_default_host) + self.assertFalse( + OpenAIClient(api_key=None, model='gpt-4o', + api_url=self.api_url).is_available()) + client = make_client() + err = LLMClientError(LLMError( + message='use v1/responses endpoint', + code='bad', provider='openai')) + self.assertTrue(client._should_use_responses_api(err)) + elif self.kind == 'host_check': # A hostname merely starting with api.openai.com must not be # treated as the official endpoint. From b3ff13180880649a973ad419e31f08e71a4c7131 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:49:38 +0530 Subject: [PATCH 7/9] Align OpenAI adaptation with structured server responses --- docs/en_US/ai_tools.rst | 7 +- docs/en_US/preferences.rst | 9 +- web/pgadmin/llm/models.py | 2 + web/pgadmin/llm/providers/openai.py | 292 ++++---- web/pgadmin/llm/tests/test_openai_stream.py | 712 ++++++++------------ 5 files changed, 438 insertions(+), 584 deletions(-) diff --git a/docs/en_US/ai_tools.rst b/docs/en_US/ai_tools.rst index 866f36f2831..dcf78e08938 100644 --- a/docs/en_US/ai_tools.rst +++ b/docs/en_US/ai_tools.rst @@ -78,10 +78,9 @@ 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. 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 + inference servers. pgAdmin sends ``max_tokens`` by default and learns to use + ``max_completion_tokens`` when a server explicitly rejects it. 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. diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 57a68692b63..0b3327c44a7 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -66,7 +66,8 @@ Use the fields on the *AI* panel to configure your LLM provider: * 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). + use any OpenAI-compatible API provider (e.g., LiteLLM, LM Studio, EXO, + GLM (Z.ai)). Include the ``/v1`` path prefix if required by your provider (e.g., ``http://localhost:1234/v1``). @@ -101,9 +102,13 @@ Use the fields on the *AI* panel to configure your LLM provider: .. 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 + GLM (Z.ai), 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. +.. note:: With the default allowlist enabled, an administrator must add any + non-default API host to ``ALLOWED_LLM_API_URLS`` before pgAdmin can connect + to it. An empty allowlist disables this restriction. + The Browser Node **************** diff --git a/web/pgadmin/llm/models.py b/web/pgadmin/llm/models.py index ba20245cb76..b9bceddbecd 100644 --- a/web/pgadmin/llm/models.py +++ b/web/pgadmin/llm/models.py @@ -194,6 +194,8 @@ class LLMError: code: Optional[str] = None provider: Optional[str] = None retryable: bool = False + http_status: Optional[int] = None + param: Optional[str] = None def __str__(self) -> str: if self.code: diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index 3a540a96cf2..e7efbd26b91 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -10,6 +10,7 @@ """OpenAI GPT LLM client implementation.""" import json +import logging import socket import ssl import urllib.request @@ -40,12 +41,10 @@ # 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 = {} +# Learned server/model capabilities survive per-request client instances. +_adaptive_cache = {} +_logger = logging.getLogger(__name__) +_MAX_SENDS = 3 # Default API base URL DEFAULT_API_BASE_URL = 'https://api.openai.com/v1' @@ -84,7 +83,6 @@ def __init__(self, api_key: Optional[str] = None, base_url = base_url[:-len(suffix)].rstrip('/') break self._base_url = base_url - self._use_responses_api = False @property def _api_url(self) -> str: @@ -141,29 +139,53 @@ def chat( Raises: LLMClientError: If the request fails. """ - if self._use_responses_api: - return self._chat_responses( - 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'): + for attempt in range(_MAX_SENDS): + budget = max_tokens * (3 if self._capabilities.get('reasoning') + else 1) try: + if self._use_responses_api: + return self._chat_responses( + messages, tools, system_prompt, budget) 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 - ) + messages, tools, system_prompt, budget) + except LLMClientError as error: + if not self._adjust_after_error(error, attempt): + raise + + @property + def _capabilities(self) -> dict: + """Return the learned capabilities for this server and model.""" + return _adaptive_cache.setdefault((self._base_url, self._model), {}) + + @property + def _use_responses_api(self) -> bool: + """Keep routing decisions across per-request client instances.""" + return self._capabilities.get('responses', False) + + def _remember(self, capability: str, value=True): + """Log each new server/model adjustment once.""" + if self._capabilities.get(capability) != value: + self._capabilities[capability] = value + # Do not log API keys, URLs, prompts or provider error bodies. + _logger.debug('OpenAI provider learned %s adjustment', capability) + + def _adjust_after_error(self, error: LLMClientError, + attempt: int) -> bool: + """Apply each learned adjustment once, with at most three sends.""" + adjusted = False + if (self._is_unsupported_token_param_error(error) and + not self._capabilities.get('completion_tokens')): + self._remember('completion_tokens') + adjusted = True + elif (self._should_use_responses_api(error) and + not self._use_responses_api): + self._remember('responses') + adjusted = True + elif (error.error.code == 'reasoning_budget' and + not self._capabilities.get('reasoning')): + self._remember('reasoning') + adjusted = True + return adjusted and attempt + 1 < _MAX_SENDS def _chat_completions( self, @@ -172,33 +194,12 @@ def _chat_completions( system_prompt: Optional[str], max_tokens: int ) -> LLMResponse: - """Send a chat request using the Chat Completions API.""" - - # Try Chat Completions API first + """Send one Chat Completions request; the caller bounds retries.""" payload = self._build_chat_payload( - messages, tools, system_prompt, max_tokens - ) - + messages, tools, system_prompt, max_tokens) try: - 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) - if self._should_use_responses_api(e): - self._use_responses_api = True - return self._chat_responses( - messages, tools, system_prompt, max_tokens - ) + return self._parse_response(self._make_request(payload)) + except LLMClientError: raise except Exception as e: raise LLMClientError(LLMError( @@ -231,28 +232,20 @@ 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) + detail = error.error + return (getattr(detail, 'http_status', None) in (400, 404) and + not getattr(detail, 'param', None) and + 'only supported in' in detail.message.lower() and + 'v1/responses' in detail.message.lower()) 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' - ) + """Require the server's structured rejection of max_tokens.""" + detail = error.error + return (getattr(detail, 'http_status', None) == 400 and + getattr(detail, 'param', None) == 'max_tokens' and + detail.code == 'unsupported_parameter') def _build_chat_payload( self, @@ -270,9 +263,9 @@ def _build_chat_payload( 'content': system_prompt }) - token_param = _token_param_cache.get( - (self._base_url, self._model), 'max_completion_tokens' - ) + token_param = ('max_completion_tokens' + if self._capabilities.get('completion_tokens') + else 'max_tokens') payload = { 'model': self._model, 'messages': converted_messages, @@ -443,6 +436,7 @@ def _http_error_to_llm_error( error_body = e.read().decode('utf-8') error_msg = None error_code = None + error_param = None try: error_data = json.loads(error_body) except json.JSONDecodeError: @@ -453,9 +447,7 @@ def _http_error_to_llm_error( 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_param = error_info.get('param') error_code = error_info.get('code') elif isinstance(error_info, str): error_msg = error_info @@ -466,7 +458,9 @@ def _http_error_to_llm_error( message=error_msg, code=error_code or str(e.code), provider=self.provider_name, - retryable=e.code in (429, 500, 502, 503, 504) + retryable=e.code in (429, 500, 502, 503, 504), + http_status=e.code, + param=error_param ) def _make_request(self, payload: dict) -> dict: @@ -523,6 +517,16 @@ def _raise_max_tokens_error(self, input_tokens: int): retryable=False )) + def _raise_reasoning_budget_error(self): + """Explain empty output caused by a model's reasoning budget.""" + raise LLMClientError(LLMError( + message=f'Reasoning model {self._model} exhausted its output ' + f'budget on internal reasoning and returned no content.', + code='reasoning_budget', + provider=self.provider_name, + retryable=True + )) + def _parse_response(self, data: dict) -> LLMResponse: """Parse the Chat Completions API response into an LLMResponse.""" # Check for API-level errors in the response @@ -590,25 +594,20 @@ def _parse_response(self, data: dict) -> LLMResponse: total_tokens=usage_data.get('total_tokens', 0) ) + details = usage_data.get('completion_tokens_details') or {} + reasoning = (bool(message.get('reasoning_content')) or + (details.get('reasoning_tokens') or 0) > 0) # 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) + reasoning_tokens = details.get('reasoning_tokens') or 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_reasoning_budget_error() self._raise_max_tokens_error(usage.input_tokens) elif finish_reason and finish_reason not in ('stop', 'tool_calls'): raise LLMClientError(LLMError( @@ -619,6 +618,9 @@ def _parse_response(self, data: dict) -> LLMResponse: retryable=False )) + if reasoning: + self._remember('reasoning') + return LLMResponse( content=content, tool_calls=tool_calls, @@ -693,9 +695,14 @@ def _parse_responses_response(self, data: dict) -> LLMResponse: total_tokens=usage_data.get('total_tokens', 0) ) + details = usage_data.get('output_tokens_details') or {} + reasoning = (details.get('reasoning_tokens') or 0) > 0 + # Check for problematic responses if not content and not tool_calls: if stop_reason == StopReason.MAX_TOKENS: + if reasoning: + self._raise_reasoning_budget_error() self._raise_max_tokens_error(usage.input_tokens) elif stop_reason == StopReason.STOP_SEQUENCE: raise LLMClientError(LLMError( @@ -705,6 +712,9 @@ def _parse_responses_response(self, data: dict) -> LLMResponse: retryable=False )) + if reasoning: + self._remember('reasoning') + return LLMResponse( content=content, tool_calls=tool_calls, @@ -724,70 +734,32 @@ def chat_stream( **kwargs ) -> Generator[Union[str, LLMResponse], None, None]: """Stream a chat response from OpenAI.""" - if self._use_responses_api: - payload = self._build_responses_payload( - messages, tools, system_prompt, max_tokens - ) + for attempt in range(_MAX_SENDS): + budget = max_tokens * (3 if self._capabilities.get('reasoning') + else 1) + if self._use_responses_api: + payload = self._build_responses_payload( + messages, tools, system_prompt, budget) + else: + payload = self._build_chat_payload( + messages, tools, system_prompt, budget) + payload['stream_options'] = {'include_usage': True} payload['stream'] = True + yielded = False try: - yield from self._process_stream(payload) - except LLMClientError: - raise + for item in self._process_stream(payload): + yielded = True + yield item + return + except LLMClientError as error: + # Never replay a stream once visible output has escaped. + if yielded or not self._adjust_after_error(error, attempt): + raise except Exception as e: raise LLMClientError(LLMError( message=f"Streaming request failed: {e!s}", provider=self.provider_name )) from e - return - - # Try Chat Completions API first - payload = self._build_chat_payload( - messages, tools, system_prompt, max_tokens - ) - payload['stream'] = True - payload['stream_options'] = {'include_usage': True} - - try: - yield from self._process_stream(payload) - except LLMClientError as 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 - ) - payload['stream'] = True - yield from self._process_stream(payload) - else: - raise - except Exception as e: - raise LLMClientError(LLMError( - message=f"Streaming request failed: {e!s}", - provider=self.provider_name - )) from e def _process_stream( self, payload: dict @@ -883,7 +855,7 @@ def _read_openai_stream( total_tokens=u.get('total_tokens', 0) ) details = u.get('completion_tokens_details') or {} - reasoning_tokens = details.get('reasoning_tokens', 0) + reasoning_tokens = details.get('reasoning_tokens') or 0 if 'model' in data: model_name = data['model'] @@ -961,21 +933,16 @@ def _read_openai_stream( ): # 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 - )) + self._raise_reasoning_budget_error() raise LLMClientError(LLMError( message='No response content returned from API', provider=self.provider_name, retryable=False )) + if reasoning_seen or reasoning_tokens > 0: + self._remember('reasoning') + yield LLMResponse( content=content, tool_calls=tool_calls, @@ -1031,6 +998,7 @@ def tool_call_entry(output_index, item_id): usage = Usage() resp_status = '' resp_incomplete = {} + reasoning_seen = False while True: line_bytes = response.readline() @@ -1088,7 +1056,11 @@ def tool_call_entry(output_index, item_id): data.get('output_index'), data.get('item_id')) tc['arguments'] += data.get('delta', '') - elif event_type == 'response.completed': + elif event_type in ('response.reasoning_text.delta', + 'response.reasoning_summary_text.delta'): + reasoning_seen = True + + elif event_type in ('response.completed', 'response.incomplete'): resp = data.get('response', {}) u = resp.get('usage', {}) usage = Usage( @@ -1096,6 +1068,9 @@ def tool_call_entry(output_index, item_id): output_tokens=u.get('output_tokens', 0), total_tokens=u.get('total_tokens', 0) ) + details = u.get('output_tokens_details') or {} + reasoning_seen = reasoning_seen or ( + details.get('reasoning_tokens') or 0) > 0 model_name = resp.get('model', model_name) resp_status = resp.get('status', '') resp_incomplete = resp.get('incomplete_details', {}) @@ -1131,12 +1106,17 @@ def tool_call_entry(output_index, item_id): stop_reason = StopReason.UNKNOWN if not content and not tool_calls: + if stop_reason == StopReason.MAX_TOKENS and reasoning_seen: + self._raise_reasoning_budget_error() raise LLMClientError(LLMError( message='No response content returned from API', provider=self.provider_name, retryable=False )) + if reasoning_seen: + self._remember('reasoning') + yield LLMResponse( content=content, tool_calls=tool_calls, diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index d5a66081e45..1fc1b2ff977 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -281,430 +281,298 @@ def runTest(self): class OpenAICompatibleServerTestCase(BaseTestGenerator): - """Adaptive behaviour for OpenAI-compatible servers and reasoning models. + """Check adaptive requests using scripted transport, without API calls.""" - Each scenario drives chat() with _make_request replaced by a script of - canned responses/errors, and asserts on the requests that would have - been sent. No network access is involved. - """ - - scenarios = [ - ('Unsupported max_completion_tokens falls back to max_tokens', dict( - kind='token_fallback', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Token parameter fallback is remembered for new clients', dict( - kind='token_cache', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Custom host is never rerouted to the Responses API', dict( - kind='no_reroute', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Reasoning exhaustion retries with a tripled budget', dict( - kind='reasoning_retry', - api_url='http://localhost:1234/v1', - model='deepseek-r1', - )), - ('Lookalike hostname is treated as a custom provider', dict( - kind='host_check', - api_url='https://api.openai.com.evil.example/v1', - model='gpt-4o', - )), - ('Streaming falls back to max_tokens on unsupported parameter', dict( - kind='stream_token_fallback', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Structured error fields still trigger the fallback', dict( - kind='structured_token_fallback', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Reasoning budget is retried only once', dict( - kind='reasoning_retry_once', - api_url='http://localhost:1234/v1', - model='deepseek-r1', - )), - ('Streaming falls back on structured error fields', dict( - kind='stream_structured_fallback', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Malformed error bodies still produce a usable error', dict( - kind='malformed_error_body', - api_url='http://localhost:1234/v1', - model='glm-4', - )), - ('Reasoning exhaustion detected from usage details', dict( - kind='reasoning_from_usage', - api_url='http://localhost:1234/v1', - model='o3-mini', - )), - ('Streaming reasoning exhaustion retries with tripled budget', dict( - kind='stream_reasoning_retry', - api_url='http://localhost:1234/v1', - model='deepseek-r1', - )), - ('Default host with explicit port is still the official API', dict( - kind='explicit_port_host', - api_url='https://api.openai.com:443/v1', - model='gpt-4o', - )), - ] + scenarios = [(name, dict(check=name)) for name in ( + 'compatible_default', 'token_fallback', 'token_cache', + 'unrelated_error', 'structured_only', 'responses_cache', + 'retry_chain', 'retry_cap', 'reasoning_retry', 'reasoning_cache', + 'reasoning_success', 'reasoning_null', 'reasoning_exhausted', + 'partial_stream', 'malformed_errors', 'hostnames', + 'reasoning_content', 'responses_rejection', 'three_send_cap', + 'http_fallback', 'responses_reasoning', 'debug_once')] def runTest(self): - from pgadmin.llm.providers import openai as openai_module + from pgadmin.llm.providers import openai as module + module._adaptive_cache.clear() + try: + getattr(self, '_check_' + self.check)(False) + module._adaptive_cache.clear() + if self.check not in ('partial_stream', 'malformed_errors', + 'hostnames'): + getattr(self, '_check_' + self.check)(True) + finally: + module._adaptive_cache.clear() + + def _client(self, url='https://gateway.example/v1', model='test-model'): + return OpenAIClient(api_key='test-key', api_url=url, model=model) + + def _error(self, param='max_tokens', code='unsupported_parameter', + status=400, message='Unsupported parameter'): + import io + import urllib.error + from pgadmin.llm.client import LLMClientError + body = json.dumps({'error': dict(param=param, code=code, + message=message)}).encode() + return LLMClientError(self._client()._http_error_to_llm_error( + urllib.error.HTTPError('https://gateway.example', status, + 'error', {}, io.BytesIO(body)))) + + def _ok(self, reasoning=None, content='hello', finish='stop'): + return dict(choices=[dict(message=dict(content=content), + finish_reason=finish)], + usage=dict(completion_tokens_details=dict( + reasoning_tokens=reasoning))) + + def _run(self, client, script, streaming, budget=500): + from pgadmin.llm.models import Message + calls = [] + + def request(payload): + calls.append((client._api_url, payload.copy())) + item = script[len(calls) - 1] + if isinstance(item, Exception): + raise item + return item + + def stream(payload): + item = request(payload) + if client._use_responses_api: + events = [] + for output in item.get('output', []): + for part in output.get('content', []): + events.append(_sse(dict( + type='response.output_text.delta', + delta=part['text']))) + events.append(_sse(dict(type='response.completed', + response=item))) + yield from client._read_responses_stream(_FakeStream(events)) + else: + choice = item['choices'][0] + yield from client._read_openai_stream(_FakeStream([ + _sse(dict(choices=[dict( + delta=choice['message'], + finish_reason=choice['finish_reason'])], + usage=item.get('usage', {})))])) + + client._make_request = request + client._process_stream = stream + self.calls = calls + messages = [Message.user('hi')] + if streaming: + return list(client.chat_stream(messages, max_tokens=budget)) + return client.chat(messages, max_tokens=budget) + + def _budgets(self): + return [next(p[k] for k in ('max_tokens', 'max_completion_tokens', + 'max_output_tokens') if k in p) + for _, p in self.calls] + + def _check_compatible_default(self, streaming): + self._run(self._client(), [self._ok()], streaming) + self.assertIn('max_tokens', self.calls[0][1]) + + def _check_token_fallback(self, streaming): + self._run(self._client(), [self._error(), self._ok()], streaming) + self.assertIn('max_tokens', self.calls[0][1]) + self.assertIn('max_completion_tokens', self.calls[1][1]) + + def _check_token_cache(self, streaming): + self._check_token_fallback(streaming) + self._run(self._client(), [self._ok()], streaming) + self.assertIn('max_completion_tokens', self.calls[0][1]) + self._run(self._client(model='other'), [self._ok()], streaming) + self.assertIn('max_tokens', self.calls[0][1]) + self._run(self._client(url='https://other.example/v1'), + [self._ok()], streaming) + self.assertIn('max_tokens', self.calls[0][1]) + + def _check_unrelated_error(self, streaming): from pgadmin.llm.client import LLMClientError - from pgadmin.llm.models import LLMError, Message - - openai_module._token_param_cache.clear() - - def make_client(): - return OpenAIClient( - api_key='test-key', model=self.model, api_url=self.api_url) - - def ok_response(content='hello'): - return { - 'choices': [{'message': {'content': content}, - 'finish_reason': 'stop'}], - 'usage': {'prompt_tokens': 1, 'completion_tokens': 1, - 'total_tokens': 2}, - 'model': self.model, - } - - if self.kind == 'token_fallback': - client = make_client() - sent_params = [] - - def fake_request(payload): - sent_params.append( - 'max_completion_tokens' - if 'max_completion_tokens' in payload else 'max_tokens') - if sent_params[-1] == 'max_completion_tokens': - raise LLMClientError(LLMError( - message="Unsupported parameter: " - "'max_completion_tokens' is not supported " - "with this model.", - code='unsupported_parameter', - provider='openai')) - return ok_response() - - client._make_request = fake_request - result = client.chat([Message(role='user', content='hi')]) - self.assertEqual( - sent_params, ['max_completion_tokens', 'max_tokens']) - self.assertEqual(result.content, 'hello') - - elif self.kind == 'token_cache': - # A new client for the same (server, model) pair must use the - # remembered parameter immediately, without another rejection. - openai_module._token_param_cache[ - ('http://localhost:1234/v1', 'glm-4')] = 'max_tokens' - client = make_client() - sent_params = [] - - def fake_request(payload): - sent_params.append( - 'max_completion_tokens' - if 'max_completion_tokens' in payload else 'max_tokens') - return ok_response() - - client._make_request = fake_request - result = client.chat([Message(role='user', content='hi')]) - self.assertEqual(sent_params, ['max_tokens']) - self.assertEqual(result.content, 'hello') - - elif self.kind == 'no_reroute': - client = make_client() - urls = [] - - def fake_request(payload): - urls.append(client._api_url) - raise LLMClientError(LLMError( - message='This model requires the v1/responses endpoint', - code='bad_request', provider='openai')) - - client._make_request = fake_request + error = self._error(param=None, message='not a chat model') + with self.assertRaises(LLMClientError): + self._run(self._client(), [error], streaming) + self.assertEqual(len(self.calls), 1) + + def _check_structured_only(self, streaming): + from pgadmin.llm.client import LLMClientError + for error in (self._error(param='other'), + self._error(status=500), + self._error(code='other')): with self.assertRaises(LLMClientError): - client.chat([Message(role='user', content='hi')]) - self.assertEqual( - urls, ['http://localhost:1234/v1/chat/completions']) - - elif self.kind == 'reasoning_retry': - client = make_client() - budgets = [] - - def fake_request(payload): - budgets.append( - payload.get('max_completion_tokens') or - payload.get('max_tokens')) - if len(budgets) == 1: - return { - 'choices': [{'message': { - 'content': '', - 'reasoning_content': 'thinking...'}, - 'finish_reason': 'length'}], - 'usage': {'prompt_tokens': 1, 'completion_tokens': 100, - 'total_tokens': 101}, - 'model': self.model, - } - return ok_response('done') - - client._make_request = fake_request - result = client.chat( - [Message(role='user', content='hi')], max_tokens=1000) - self.assertEqual(budgets, [1000, 3000]) - self.assertEqual(result.content, 'done') - - elif self.kind == 'stream_token_fallback': - client = make_client() - sent_params = [] - - def fake_process_stream(payload): - sent_params.append( - 'max_completion_tokens' - if 'max_completion_tokens' in payload else 'max_tokens') - self.assertTrue(payload.get('stream')) - if sent_params[-1] == 'max_completion_tokens': - raise LLMClientError(LLMError( - message="Unsupported parameter: " - "'max_completion_tokens' is not supported " - "with this model.", - code='unsupported_parameter', - provider='openai')) - yield 'chunk' - - client._process_stream = fake_process_stream - chunks = list(client.chat_stream( - [Message(role='user', content='hi')])) - self.assertEqual( - sent_params, ['max_completion_tokens', 'max_tokens']) - self.assertEqual(chunks, ['chunk']) - - elif self.kind == 'structured_token_fallback': - # A server reporting the rejection only through structured - # error fields (generic message, code + parameter) must still - # trigger the fallback. - client = make_client() - sent_params = [] - - def fake_request(payload): - sent_params.append( - 'max_completion_tokens' - if 'max_completion_tokens' in payload else 'max_tokens') - if sent_params[-1] == 'max_completion_tokens': - raise LLMClientError(LLMError( - message="Invalid request (parameter: " - "'max_completion_tokens')", - code='unsupported_parameter', - provider='openai')) - return ok_response() - - client._make_request = fake_request - result = client.chat([Message(role='user', content='hi')]) - self.assertEqual( - sent_params, ['max_completion_tokens', 'max_tokens']) - self.assertEqual(result.content, 'hello') - - elif self.kind == 'reasoning_retry_once': - # The tripled-budget retry happens once; a second exhausted - # response surfaces the budget error to the caller. - client = make_client() - budgets = [] - - def fake_request(payload): - budgets.append( - payload.get('max_completion_tokens') or - payload.get('max_tokens')) - return { - 'choices': [{'message': { - 'content': '', - 'reasoning_content': 'thinking...'}, - 'finish_reason': 'length'}], - 'usage': {'prompt_tokens': 1, 'completion_tokens': 100, - 'total_tokens': 101}, - 'model': self.model, - } - - client._make_request = fake_request - with self.assertRaises(LLMClientError) as ctx: - client.chat( - [Message(role='user', content='hi')], max_tokens=1000) - self.assertEqual(budgets, [1000, 3000]) - self.assertEqual(ctx.exception.error.code, 'reasoning_budget') - - elif self.kind == 'stream_structured_fallback': - # Exercises the real _process_stream HTTP error branch: a - # streaming server reporting the rejection only through - # structured error fields must still trigger the fallback. - import io - import urllib.error - - client = make_client() - calls = [] - body = json.dumps({'error': { - 'message': 'Invalid request', - 'code': 'unsupported_parameter', - 'param': 'max_completion_tokens'}}).encode('utf-8') - - def fake_urlopen(request, timeout=None, context=None): - payload = json.loads(request.data.decode('utf-8')) - calls.append( - 'max_completion_tokens' - if 'max_completion_tokens' in payload else 'max_tokens') - if calls[-1] == 'max_completion_tokens': - raise urllib.error.HTTPError( - request.full_url, 400, 'Bad Request', {}, - io.BytesIO(body)) - stream = _FakeStream([ - _sse({'choices': [{'index': 0, 'delta': { - 'role': 'assistant', 'content': 'hi'}}]}), - _sse({'choices': [{'index': 0, - 'finish_reason': 'stop', - 'delta': {}}]}), - 'data: [DONE]', - ]) - stream.close = lambda: None - return stream - - original = openai_module.urlopen_no_redirect - openai_module.urlopen_no_redirect = fake_urlopen - try: - chunks = list(client.chat_stream( - [Message(role='user', content='hi')])) - finally: - openai_module.urlopen_no_redirect = original - self.assertEqual( - calls, ['max_completion_tokens', 'max_tokens']) - self.assertIn('hi', chunks) - - elif self.kind == 'malformed_error_body': - import io - import urllib.error - - client = make_client() - - def http_error(body): - return urllib.error.HTTPError( - 'http://localhost:1234/v1/chat/completions', - 400, 'Bad Request', {}, - io.BytesIO(body.encode('utf-8'))) - - # error as a plain string - err = client._http_error_to_llm_error( - http_error('{"error": "invalid model"}')) - self.assertEqual(err.message, 'invalid model') - self.assertEqual(err.code, '400') - - # JSON null body - err = client._http_error_to_llm_error(http_error('null')) - self.assertEqual(err.message, 'null') - - # non-JSON body - err = client._http_error_to_llm_error(http_error('oops')) - self.assertEqual(err.message, 'oops') - - elif self.kind == 'reasoning_from_usage': - # No reasoning_content in the message; the reasoning spend is - # only visible in usage.completion_tokens_details. - client = make_client() - budgets = [] - - def fake_request(payload): - budgets.append( - payload.get('max_completion_tokens') or - payload.get('max_tokens')) - if len(budgets) == 1: - return { - 'choices': [{'message': {'content': ''}, - 'finish_reason': 'length'}], - 'usage': { - 'prompt_tokens': 1, 'completion_tokens': 100, - 'total_tokens': 101, - 'completion_tokens_details': { - 'reasoning_tokens': 100}}, - 'model': self.model, - } - return ok_response('done') - - client._make_request = fake_request - result = client.chat( - [Message(role='user', content='hi')], max_tokens=1000) - self.assertEqual(budgets, [1000, 3000]) - self.assertEqual(result.content, 'done') - - elif self.kind == 'stream_reasoning_retry': - import io - import urllib.error # noqa: F401 - - client = make_client() - budgets = [] - - exhausted_stream = _FakeStream([ - _sse({'choices': [{'index': 0, 'delta': { - 'role': 'assistant', - 'reasoning_content': 'thinking...'}}]}), - _sse({'choices': [{'index': 0, - 'finish_reason': 'length', - 'delta': {}}], - 'usage': { - 'prompt_tokens': 1, 'completion_tokens': 100, - 'total_tokens': 101, - 'completion_tokens_details': { - 'reasoning_tokens': 100}}}), - 'data: [DONE]', - ]) - ok_stream = _FakeStream([ - _sse({'choices': [{'index': 0, 'delta': { - 'role': 'assistant', 'content': 'hi'}}]}), - _sse({'choices': [{'index': 0, - 'finish_reason': 'stop', - 'delta': {}}]}), - 'data: [DONE]', - ]) - for s in (exhausted_stream, ok_stream): - s.close = lambda: None - streams = [exhausted_stream, ok_stream] - - def fake_urlopen(request, timeout=None, context=None): - payload = json.loads(request.data.decode('utf-8')) - budgets.append( - payload.get('max_completion_tokens') or - payload.get('max_tokens')) - return streams[len(budgets) - 1] - - original = openai_module.urlopen_no_redirect - openai_module.urlopen_no_redirect = fake_urlopen - try: - chunks = list(client.chat_stream( - [Message(role='user', content='hi')], max_tokens=1000)) - finally: - openai_module.urlopen_no_redirect = original - self.assertEqual(budgets, [1000, 3000]) - self.assertIn('hi', chunks) - - elif self.kind == 'explicit_port_host': - # An explicit default port must not make the official endpoint - # look like a custom provider. - self.assertTrue(make_client()._is_default_host) - self.assertFalse( - OpenAIClient(api_key=None, model='gpt-4o', - api_url=self.api_url).is_available()) - client = make_client() - err = LLMClientError(LLMError( - message='use v1/responses endpoint', - code='bad', provider='openai')) - self.assertTrue(client._should_use_responses_api(err)) - - elif self.kind == 'host_check': - # A hostname merely starting with api.openai.com must not be - # treated as the official endpoint. - self.assertTrue(make_client().is_available()) - self.assertFalse( - OpenAIClient(api_key=None, model='gpt-4o').is_available()) - - openai_module._token_param_cache.clear() + self._run(self._client(), [error], streaming) + self.assertEqual(len(self.calls), 1) + + def _route_error(self): + return self._error( + param=None, status=404, + message='This model is only supported in v1/responses') + + def _responses_ok(self): + return dict(status='completed', output=[dict(type='message', + content=[dict(type='output_text', text='hello')])]) + + def _check_responses_cache(self, streaming): + self._run(self._client(), [self._route_error(), + self._responses_ok()], streaming) + self.assertTrue(self.calls[-1][0].endswith('/responses')) + self._run(self._client(), [self._responses_ok()], streaming) + self.assertTrue(self.calls[0][0].endswith('/responses')) + + def _check_retry_chain(self, streaming): + self._run(self._client(), [self._error(), self._route_error(), + self._responses_ok()], streaming) + self.assertEqual(len(self.calls), 3) + + def _check_retry_cap(self, streaming): + from pgadmin.llm.client import LLMClientError + with self.assertRaises(LLMClientError): + self._run(self._client(), [ + self._error(), self._error()], streaming) + self.assertEqual(len(self.calls), 2) + + def _exhausted(self): + return self._ok(reasoning=10, content='', finish='length') + + def _check_reasoning_retry(self, streaming): + self._run(self._client(), [self._error(), self._exhausted(), + self._ok()], streaming) + self.assertEqual(self._budgets(), [500, 500, 1500]) + + def _check_reasoning_cache(self, streaming): + self._check_reasoning_retry(streaming) + self._run(self._client(), [self._ok()], streaming) + self.assertEqual(self._budgets(), [1500]) + + def _check_reasoning_success(self, streaming): + self._run(self._client(), [self._ok(reasoning=10)], streaming) + self._run(self._client(), [self._ok()], streaming) + self.assertEqual(self._budgets(), [1500]) + + def _check_reasoning_null(self, streaming): + self._run(self._client(), [self._ok(reasoning=None)], streaming) + self.assertEqual(self._budgets(), [500]) + + def _check_reasoning_exhausted(self, streaming): + from pgadmin.llm.client import LLMClientError + with self.assertRaises(LLMClientError) as caught: + self._run(self._client(), [self._exhausted(), + self._exhausted()], streaming) + self.assertEqual(caught.exception.error.code, 'reasoning_budget') + self.assertEqual(self._budgets(), [500, 1500]) + + def _check_partial_stream(self, streaming): + from pgadmin.llm.client import LLMClientError + from pgadmin.llm.models import Message + client = self._client() + calls = [] + + def partial(payload): + calls.append(payload) + yield 'hello' + raise self._error() + + client._process_stream = partial + response = client.chat_stream([Message.user('hi')]) + self.assertEqual(next(response), 'hello') + with self.assertRaises(LLMClientError): + next(response) + self.assertEqual(len(calls), 1) + + def _check_malformed_errors(self, streaming): + import io + import urllib.error + for body in (b'[]', b'{"error": []}', b'{"error": null}', + b'not json'): + error = self._client()._http_error_to_llm_error( + urllib.error.HTTPError('https://gateway.example', 400, + 'error', {}, io.BytesIO(body))) + self.assertTrue(error.message) + self.assertEqual(error.http_status, 400) + + def _check_hostnames(self, streaming): + self.assertTrue(OpenAIClient( + api_url='https://api.openai.com.evil.example/v1').is_available()) + self.assertFalse(OpenAIClient( + api_url='https://api.openai.com:443/v1').is_available()) + + def _check_reasoning_content(self, streaming): + response = self._ok(content='', finish='length') + response['choices'][0]['message']['reasoning_content'] = 'thinking' + self._run(self._client(), [response, self._ok()], streaming) + self.assertEqual(self._budgets(), [500, 1500]) + + def _check_responses_rejection(self, streaming): + from pgadmin.llm.client import LLMClientError + for error in ( + self._error(param='model', + message='Model only supported in v1/responses'), + self._error( + param=None, status=500, + message='Model only supported in v1/responses')): + with self.assertRaises(LLMClientError): + self._run(self._client(), [error], streaming) + self.assertEqual(len(self.calls), 1) + + def _check_three_send_cap(self, streaming): + from pgadmin.llm.client import LLMClientError + with self.assertRaises(LLMClientError): + self._run(self._client(), [self._error(), self._exhausted(), + self._route_error()], streaming) + self.assertEqual(len(self.calls), 3) + + def _check_http_fallback(self, streaming): + """Exercise the real request and error parser on both transports.""" + import io + import urllib.error + from unittest.mock import patch + from pgadmin.llm.models import Message + client = self._client() + sent = [] + + class Response(io.BytesIO): + pass + + def urlopen(request, **kwargs): + payload = json.loads(request.data) + sent.append(payload) + if len(sent) == 1: + body = json.dumps({'error': dict( + param='max_tokens', code='unsupported_parameter', + message='Unsupported')}) + raise urllib.error.HTTPError( + request.full_url, 400, 'Bad Request', {}, + io.BytesIO(body.encode())) + if streaming: + body = _sse(dict(choices=[dict(delta=dict(content='hello'), + finish_reason='stop')])) + '\n' + else: + body = json.dumps(self._ok()) + return Response(body.encode()) + + with patch('pgadmin.llm.providers.openai.urlopen_no_redirect', + side_effect=urlopen): + if streaming: + list(client.chat_stream([Message.user('hi')])) + else: + client.chat([Message.user('hi')]) + self.assertIn('max_tokens', sent[0]) + self.assertIn('max_completion_tokens', sent[1]) + + def _check_responses_reasoning(self, streaming): + """Reasoning budgets also work after a Responses API reroute.""" + exhausted = dict( + status='incomplete', output=[], + incomplete_details=dict(reason='max_output_tokens'), + usage=dict(output_tokens_details=dict(reasoning_tokens=10))) + self._run(self._client(), [self._route_error(), exhausted, + self._responses_ok()], streaming) + self.assertEqual(self._budgets(), [500, 500, 1500]) + self._run(self._client(), [self._responses_ok()], streaming) + self.assertEqual(self._budgets(), [1500]) + + def _check_debug_once(self, streaming): + from unittest.mock import patch + with patch('pgadmin.llm.providers.openai._logger.debug') as debug: + self._check_token_cache(streaming) + debug.assert_called_once() From ac4665ffa010830e145f7b08e66bd75110c35ee6 Mon Sep 17 00:00:00 2001 From: Sanskar Kharya <178012652+MaybeSomeone-arc18@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:06:51 +0530 Subject: [PATCH 8/9] Handle Responses-only model error parameters --- web/pgadmin/llm/providers/openai.py | 2 +- web/pgadmin/llm/tests/test_openai_stream.py | 47 ++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/web/pgadmin/llm/providers/openai.py b/web/pgadmin/llm/providers/openai.py index e7efbd26b91..e272ee36ef3 100644 --- a/web/pgadmin/llm/providers/openai.py +++ b/web/pgadmin/llm/providers/openai.py @@ -234,7 +234,7 @@ def _should_use_responses_api(self, error: LLMClientError) -> bool: """Check if the error indicates we should use the Responses API.""" detail = error.error return (getattr(detail, 'http_status', None) in (400, 404) and - not getattr(detail, 'param', None) and + getattr(detail, 'param', None) in (None, '', 'model') and 'only supported in' in detail.message.lower() and 'v1/responses' in detail.message.lower()) diff --git a/web/pgadmin/llm/tests/test_openai_stream.py b/web/pgadmin/llm/tests/test_openai_stream.py index 1fc1b2ff977..c2249ce8cc9 100644 --- a/web/pgadmin/llm/tests/test_openai_stream.py +++ b/web/pgadmin/llm/tests/test_openai_stream.py @@ -290,9 +290,11 @@ class OpenAICompatibleServerTestCase(BaseTestGenerator): 'reasoning_success', 'reasoning_null', 'reasoning_exhausted', 'partial_stream', 'malformed_errors', 'hostnames', 'reasoning_content', 'responses_rejection', 'three_send_cap', - 'http_fallback', 'responses_reasoning', 'debug_once')] + 'http_fallback', 'responses_reasoning', 'debug_once', + 'responses_model_param')] def runTest(self): + """RunTest.""" from pgadmin.llm.providers import openai as module module._adaptive_cache.clear() try: @@ -305,10 +307,12 @@ def runTest(self): module._adaptive_cache.clear() def _client(self, url='https://gateway.example/v1', model='test-model'): + """Client.""" return OpenAIClient(api_key='test-key', api_url=url, model=model) def _error(self, param='max_tokens', code='unsupported_parameter', status=400, message='Unsupported parameter'): + """Error.""" import io import urllib.error from pgadmin.llm.client import LLMClientError @@ -319,16 +323,19 @@ def _error(self, param='max_tokens', code='unsupported_parameter', 'error', {}, io.BytesIO(body)))) def _ok(self, reasoning=None, content='hello', finish='stop'): + """Ok.""" return dict(choices=[dict(message=dict(content=content), finish_reason=finish)], usage=dict(completion_tokens_details=dict( reasoning_tokens=reasoning))) def _run(self, client, script, streaming, budget=500): + """Run.""" from pgadmin.llm.models import Message calls = [] def request(payload): + """Request.""" calls.append((client._api_url, payload.copy())) item = script[len(calls) - 1] if isinstance(item, Exception): @@ -336,6 +343,7 @@ def request(payload): return item def stream(payload): + """Stream.""" item = request(payload) if client._use_responses_api: events = [] @@ -364,20 +372,24 @@ def stream(payload): return client.chat(messages, max_tokens=budget) def _budgets(self): + """Budgets.""" return [next(p[k] for k in ('max_tokens', 'max_completion_tokens', 'max_output_tokens') if k in p) for _, p in self.calls] def _check_compatible_default(self, streaming): + """Check compatible default.""" self._run(self._client(), [self._ok()], streaming) self.assertIn('max_tokens', self.calls[0][1]) def _check_token_fallback(self, streaming): + """Check token fallback.""" self._run(self._client(), [self._error(), self._ok()], streaming) self.assertIn('max_tokens', self.calls[0][1]) self.assertIn('max_completion_tokens', self.calls[1][1]) def _check_token_cache(self, streaming): + """Check token cache.""" self._check_token_fallback(streaming) self._run(self._client(), [self._ok()], streaming) self.assertIn('max_completion_tokens', self.calls[0][1]) @@ -388,6 +400,7 @@ def _check_token_cache(self, streaming): self.assertIn('max_tokens', self.calls[0][1]) def _check_unrelated_error(self, streaming): + """Check unrelated error.""" from pgadmin.llm.client import LLMClientError error = self._error(param=None, message='not a chat model') with self.assertRaises(LLMClientError): @@ -395,6 +408,7 @@ def _check_unrelated_error(self, streaming): self.assertEqual(len(self.calls), 1) def _check_structured_only(self, streaming): + """Check structured only.""" from pgadmin.llm.client import LLMClientError for error in (self._error(param='other'), self._error(status=500), @@ -404,15 +418,18 @@ def _check_structured_only(self, streaming): self.assertEqual(len(self.calls), 1) def _route_error(self): + """Route error.""" return self._error( param=None, status=404, message='This model is only supported in v1/responses') def _responses_ok(self): + """Responses ok.""" return dict(status='completed', output=[dict(type='message', content=[dict(type='output_text', text='hello')])]) def _check_responses_cache(self, streaming): + """Check responses cache.""" self._run(self._client(), [self._route_error(), self._responses_ok()], streaming) self.assertTrue(self.calls[-1][0].endswith('/responses')) @@ -420,11 +437,13 @@ def _check_responses_cache(self, streaming): self.assertTrue(self.calls[0][0].endswith('/responses')) def _check_retry_chain(self, streaming): + """Check retry chain.""" self._run(self._client(), [self._error(), self._route_error(), self._responses_ok()], streaming) self.assertEqual(len(self.calls), 3) def _check_retry_cap(self, streaming): + """Check retry cap.""" from pgadmin.llm.client import LLMClientError with self.assertRaises(LLMClientError): self._run(self._client(), [ @@ -432,28 +451,34 @@ def _check_retry_cap(self, streaming): self.assertEqual(len(self.calls), 2) def _exhausted(self): + """Exhausted.""" return self._ok(reasoning=10, content='', finish='length') def _check_reasoning_retry(self, streaming): + """Check reasoning retry.""" self._run(self._client(), [self._error(), self._exhausted(), self._ok()], streaming) self.assertEqual(self._budgets(), [500, 500, 1500]) def _check_reasoning_cache(self, streaming): + """Check reasoning cache.""" self._check_reasoning_retry(streaming) self._run(self._client(), [self._ok()], streaming) self.assertEqual(self._budgets(), [1500]) def _check_reasoning_success(self, streaming): + """Check reasoning success.""" self._run(self._client(), [self._ok(reasoning=10)], streaming) self._run(self._client(), [self._ok()], streaming) self.assertEqual(self._budgets(), [1500]) def _check_reasoning_null(self, streaming): + """Check reasoning null.""" self._run(self._client(), [self._ok(reasoning=None)], streaming) self.assertEqual(self._budgets(), [500]) def _check_reasoning_exhausted(self, streaming): + """Check reasoning exhausted.""" from pgadmin.llm.client import LLMClientError with self.assertRaises(LLMClientError) as caught: self._run(self._client(), [self._exhausted(), @@ -462,12 +487,14 @@ def _check_reasoning_exhausted(self, streaming): self.assertEqual(self._budgets(), [500, 1500]) def _check_partial_stream(self, streaming): + """Check partial stream.""" from pgadmin.llm.client import LLMClientError from pgadmin.llm.models import Message client = self._client() calls = [] def partial(payload): + """Partial.""" calls.append(payload) yield 'hello' raise self._error() @@ -480,6 +507,7 @@ def partial(payload): self.assertEqual(len(calls), 1) def _check_malformed_errors(self, streaming): + """Check malformed errors.""" import io import urllib.error for body in (b'[]', b'{"error": []}', b'{"error": null}', @@ -491,21 +519,24 @@ def _check_malformed_errors(self, streaming): self.assertEqual(error.http_status, 400) def _check_hostnames(self, streaming): + """Check hostnames.""" self.assertTrue(OpenAIClient( api_url='https://api.openai.com.evil.example/v1').is_available()) self.assertFalse(OpenAIClient( api_url='https://api.openai.com:443/v1').is_available()) def _check_reasoning_content(self, streaming): + """Check reasoning content.""" response = self._ok(content='', finish='length') response['choices'][0]['message']['reasoning_content'] = 'thinking' self._run(self._client(), [response, self._ok()], streaming) self.assertEqual(self._budgets(), [500, 1500]) def _check_responses_rejection(self, streaming): + """Check responses rejection.""" from pgadmin.llm.client import LLMClientError for error in ( - self._error(param='model', + self._error(param='other', message='Model only supported in v1/responses'), self._error( param=None, status=500, @@ -515,6 +546,7 @@ def _check_responses_rejection(self, streaming): self.assertEqual(len(self.calls), 1) def _check_three_send_cap(self, streaming): + """Check three send cap.""" from pgadmin.llm.client import LLMClientError with self.assertRaises(LLMClientError): self._run(self._client(), [self._error(), self._exhausted(), @@ -534,6 +566,7 @@ class Response(io.BytesIO): pass def urlopen(request, **kwargs): + """Urlopen.""" payload = json.loads(request.data) sent.append(payload) if len(sent) == 1: @@ -572,7 +605,17 @@ def _check_responses_reasoning(self, streaming): self.assertEqual(self._budgets(), [1500]) def _check_debug_once(self, streaming): + """Check debug once.""" from unittest.mock import patch with patch('pgadmin.llm.providers.openai._logger.debug') as debug: self._check_token_cache(streaming) debug.assert_called_once() + + def _check_responses_model_param(self, streaming): + """Accept the official Responses-only model error shape.""" + self._run(self._client(), [self._error( + param='model', code=None, status=404, + message='This model is only supported in v1/responses ' + 'and not in v1/chat/completions.'), + self._responses_ok()], streaming) + self.assertTrue(self.calls[-1][0].endswith('/responses')) From e8754942347d9281d80ff8980af733ad8e0994e9 Mon Sep 17 00:00:00 2001 From: Dave Page Date: Fri, 6 Nov 2026 16:43:32 +0000 Subject: [PATCH 9/9] Describe GLM (Z.ai) as a hosted service and clarify which API hosts the default allowlist permits. --- docs/en_US/ai_tools.rst | 4 ++-- docs/en_US/preferences.rst | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/en_US/ai_tools.rst b/docs/en_US/ai_tools.rst index dcf78e08938..5363dc547f2 100644 --- a/docs/en_US/ai_tools.rst +++ b/docs/en_US/ai_tools.rst @@ -56,8 +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, GLM (Z.ai), or other local inference - servers). + LiteLLM, LM Studio, EXO, hosted services such as GLM (Z.ai), or 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. diff --git a/docs/en_US/preferences.rst b/docs/en_US/preferences.rst index 0b3327c44a7..29d0de8c2a3 100644 --- a/docs/en_US/preferences.rst +++ b/docs/en_US/preferences.rst @@ -102,12 +102,14 @@ Use the fields on the *AI* panel to configure your LLM provider: .. 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, - GLM (Z.ai), 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. + hosted services such as GLM (Z.ai), 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. .. note:: With the default allowlist enabled, an administrator must add any - non-default API host to ``ALLOWED_LLM_API_URLS`` before pgAdmin can connect - to it. An empty allowlist disables this restriction. + API host not already in ``ALLOWED_LLM_API_URLS`` before pgAdmin can connect + to it. By default the list allows the OpenAI and Anthropic APIs and loopback + addresses on any port. An empty allowlist disables this restriction. The Browser Node ****************