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/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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.