Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion langextract/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 [
Expand Down
1 change: 1 addition & 0 deletions langextract/providers/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
r'^gpt4\.',
r'^gpt-5',
r'^gpt5\.',
r'^gpt-6',
OPENAI_REASONING_PATTERN,
)
OPENAI_PRIORITY = 10
Expand Down
4 changes: 2 additions & 2 deletions skills/langextract-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export OPENAI_API_KEY="your_key"
```

Auto-routing + env-default resolution is tuned for GPT-style model IDs
(`gpt-4*`, `gpt-5*`). For OpenAI-compatible endpoints or non-GPT IDs,
pass an explicit `ModelConfig` — see `references/providers.md`.
(`gpt-4*`, `gpt-5*`, `gpt-6*`). For OpenAI-compatible endpoints or
non-GPT IDs, pass an explicit `ModelConfig` — see `references/providers.md`.

## Basic extraction

Expand Down
17 changes: 11 additions & 6 deletions skills/langextract-usage/references/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,17 @@ from the provider's schema. The OpenAI provider parallelizes batched
prompts with a `ThreadPoolExecutor` when `max_workers > 1`.

**Auto-routing scope:** the built-in OpenAI provider matches GPT-3.5,
GPT-4, GPT-5, and o-series model IDs (`^gpt-3\.5`, `^gpt-4`, `^gpt4\.`,
`^gpt-5`, `^gpt5\.`, `^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.
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. 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
Expand Down
1 change: 1 addition & 0 deletions tests/factory_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def test_unrelated_o_prefix_does_not_receive_openai_key(self):
dict(testcase_name="o3_mini", model_id="o3-mini"),
dict(testcase_name="o4_mini", model_id="o4-mini"),
dict(testcase_name="gpt35", model_id="gpt-3.5-turbo"),
dict(testcase_name="gpt6_luna", model_id="gpt-6-luna"),
)
@mock.patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True)
@mock.patch.object(openai_sdk, "OpenAI", autospec=True)
Expand Down
69 changes: 69 additions & 0 deletions tests/inference_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""
# pylint: disable=attribute-defined-outside-init

import os
from unittest import mock

from absl.testing import absltest
Expand Down Expand Up @@ -917,6 +918,74 @@ 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_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="lookalike_openai_host",
base_url="https://api.openai.com.example.com/v1",
env={},
expected_param="max_tokens",
omitted_param="max_completion_tokens",
),
)
@mock.patch("openai.OpenAI")
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))
mock_client = mock_openai_class.return_value
mock_client.chat.completions.create.return_value = mock.Mock(
choices=[
mock.Mock(
message=mock.Mock(content='{"result": "ok"}', refusal=None),
finish_reason="stop",
)
]
)
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))

call_kwargs = mock_client.chat.completions.create.call_args.kwargs
self.assertEqual(call_kwargs[expected_param], 256)
self.assertNotIn(omitted_param, call_kwargs)


class TestOpenAILanguageModel(absltest.TestCase):

Expand Down
8 changes: 8 additions & 0 deletions tests/registry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ def test_openai_patterns_cover_reasoning_and_gpt35_models(self):
resolved = router.resolve(model_id)
self.assertIs(resolved, openai.OpenAILanguageModel)

def test_openai_patterns_cover_gpt6_models(self):
providers_module.load_builtins_once()

for model_id in ("gpt-6-luna", "gpt-6.1-sol"):
with self.subTest(model_id=model_id):
resolved = router.resolve(model_id)
self.assertIs(resolved, openai.OpenAILanguageModel)

def test_openai_patterns_do_not_capture_unrelated_o_prefix(self):
providers_module.load_builtins_once()

Expand Down
Loading