diff --git a/langextract/providers/openai.py b/langextract/providers/openai.py index 889f0688..f673eb09 100644 --- a/langextract/providers/openai.py +++ b/langextract/providers/openai.py @@ -20,7 +20,9 @@ import concurrent.futures import dataclasses import logging +import os from typing import Any, Iterator, Sequence, TYPE_CHECKING +import urllib.parse import warnings from langextract.core import base_model @@ -37,6 +39,15 @@ from openai.types import chat +def _is_openai_api(base_url: str | None) -> bool: + """Returns whether requests to base_url reach OpenAI's own API.""" + if not base_url: + return True + host = urllib.parse.urlsplit(base_url).hostname or '' + # Regional endpoints such as eu.api.openai.com behave like the default. + return host == 'api.openai.com' or host.endswith('.api.openai.com') + + @router.register( *patterns.OPENAI_PATTERNS, priority=patterns.OPENAI_PRIORITY, @@ -227,7 +238,14 @@ def _build_chat_completions_params(self, prompt: str, config: dict) -> dict: api_params['response_format'] = {'type': 'json_object'} if (v := normalized_config.get('max_output_tokens')) is not None: - api_params['max_tokens'] = v + # OpenAI reasoning models reject the deprecated max_tokens, but some + # OpenAI-compatible servers only accept it. Check OPENAI_BASE_URL too + # because the SDK falls back to it when base_url is unset. + base_url = self.base_url or os.environ.get('OPENAI_BASE_URL') + if _is_openai_api(base_url): + api_params['max_completion_tokens'] = v + else: + api_params['max_tokens'] = v if (v := normalized_config.get('top_p')) is not None: api_params['top_p'] = v for key in [ diff --git a/skills/langextract-usage/references/providers.md b/skills/langextract-usage/references/providers.md index 53284dd7..b5b0bfe1 100644 --- a/skills/langextract-usage/references/providers.md +++ b/skills/langextract-usage/references/providers.md @@ -41,9 +41,14 @@ prompts with a `ThreadPoolExecutor` when `max_workers > 1`. GPT-4, GPT-5, GPT-6, and o-series model IDs (`^gpt-3\.5`, `^gpt-4`, `^gpt4\.`, `^gpt-5`, `^gpt5\.`, `^gpt-6`, `^o[1-9]`). Both GPT and o-series IDs use the environment API keys above. Routing does not -guarantee that a model supports every request option: o-series models -currently require omitting `max_output_tokens`, which this provider maps -to the unsupported `max_tokens` parameter. +guarantee that a model supports every request option. The provider sends +`max_output_tokens` as `max_completion_tokens` to the OpenAI API +(including regional `*.api.openai.com` hosts), and as `max_tokens` when +`base_url` or `OPENAI_BASE_URL` points elsewhere, because some +OpenAI-compatible servers accept only the older name. Reasoning models +behind such a `base_url` (for example o-series or GPT-5 deployments on +Azure OpenAI) still need `max_output_tokens` omitted. Reasoning tokens +count toward `max_output_tokens`. For **OpenAI-compatible endpoints** (LiteLLM, local servers, custom base URLs) or model IDs outside these families, use `ModelConfig` with an diff --git a/tests/inference_test.py b/tests/inference_test.py index 2b4d2b56..5d6ad695 100644 --- a/tests/inference_test.py +++ b/tests/inference_test.py @@ -20,10 +20,14 @@ """ # pylint: disable=attribute-defined-outside-init +import os from unittest import mock from absl.testing import absltest from absl.testing import parameterized +import openai as openai_sdk +from openai.resources import chat as chat_resources +from openai.types import chat from langextract import exceptions from langextract.core import base_model @@ -917,6 +921,95 @@ def test_openai_infer_with_parameters( ] self.assertEqual(results, expected_results) + @parameterized.named_parameters( + dict( + testcase_name="openai_api", + base_url=None, + env={}, + expected_param="max_completion_tokens", + omitted_param="max_tokens", + ), + dict( + testcase_name="custom_base_url", + base_url="http://127.0.0.1:9001/v1", + env={}, + expected_param="max_tokens", + omitted_param="max_completion_tokens", + ), + dict( + testcase_name="openai_base_url_env", + base_url=None, + env={"OPENAI_BASE_URL": "http://127.0.0.1:9001/v1"}, + expected_param="max_tokens", + omitted_param="max_completion_tokens", + ), + dict( + testcase_name="explicit_base_url_overrides_env", + base_url="https://api.openai.com/v1", + env={"OPENAI_BASE_URL": "http://127.0.0.1:9001/v1"}, + expected_param="max_completion_tokens", + omitted_param="max_tokens", + ), + dict( + testcase_name="explicit_openai_base_url", + base_url="https://api.openai.com/v1", + env={}, + expected_param="max_completion_tokens", + omitted_param="max_tokens", + ), + dict( + testcase_name="regional_openai_base_url", + base_url="https://eu.api.openai.com/v1", + env={}, + expected_param="max_completion_tokens", + omitted_param="max_tokens", + ), + dict( + testcase_name="uppercase_openai_host_with_port", + base_url="https://API.OpenAI.com:443/v1/", + env={}, + expected_param="max_completion_tokens", + omitted_param="max_tokens", + ), + dict( + testcase_name="lookalike_openai_host", + base_url="https://api.openai.com.example.com/v1", + env={}, + expected_param="max_tokens", + omitted_param="max_completion_tokens", + ), + ) + @mock.patch.object(openai_sdk, "OpenAI", autospec=True) + def test_max_output_tokens_request_parameter( + self, mock_openai_class, base_url, env, expected_param, omitted_param + ): + self.enter_context(mock.patch.dict(os.environ, env, clear=True)) + client = mock_openai_class.return_value + client.chat = chat_resources.Chat(client) + mock_create = self.enter_context( + mock.patch.object(chat_resources.Completions, "create", autospec=True) + ) + mock_create.return_value = chat.ChatCompletion( + id="test", + created=0, + model="gpt-6-luna", + object="chat.completion", + choices=[{ + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": '{"result": "ok"}'}, + }], + ) + model = openai.OpenAILanguageModel( + model_id="gpt-6-luna", api_key="test-key", base_url=base_url + ) + + list(model.infer(["test prompt"], max_output_tokens=256)) + + request = mock_create.call_args.kwargs + self.assertEqual(request[expected_param], 256) + self.assertNotIn(omitted_param, request) + class TestOpenAILanguageModel(absltest.TestCase):