From 854eb292680ac61f7b39b34d9c3d4d5548149d7a Mon Sep 17 00:00:00 2001 From: Letian Liu Date: Thu, 10 Sep 2026 03:15:38 +0800 Subject: [PATCH 1/4] fix(py/plugins/openai): map max_tokens for reasoning models (#6320) --- .../src/genkit_openai/models/model.py | 22 ++++++++- .../genkit-openai/tests/openai_model_test.py | 45 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/py/packages/genkit-openai/src/genkit_openai/models/model.py b/py/packages/genkit-openai/src/genkit_openai/models/model.py index 55d9cf0219..0158e6bba6 100644 --- a/py/packages/genkit-openai/src/genkit_openai/models/model.py +++ b/py/packages/genkit-openai/src/genkit_openai/models/model.py @@ -56,7 +56,15 @@ _GENKIT_ONLY = frozenset({'api_key', 'top_k', 'version', 'max_output_tokens', 'stop_sequences'}) -def _openai_create_kwargs(*, config: OpenAIConfig) -> dict[str, Any]: +def _uses_max_completion_tokens(model: str | None) -> bool: + """Whether a model requires the reasoning-model token-limit parameter.""" + if not model: + return False + model_id = model.rsplit('/', 1)[-1].lower() + return model_id.startswith(('o1', 'o3', 'o4', 'gpt-5', 'gpt-6')) + + +def _openai_create_kwargs(*, config: OpenAIConfig, model: str | None = None) -> dict[str, Any]: """Kwargs for chat.completions.create(). Peel Genkit-only keys. ``stop_sequences`` becomes ``stop`` when ``stop`` @@ -71,6 +79,15 @@ def _openai_create_kwargs(*, config: OpenAIConfig) -> dict[str, Any]: continue value = getattr(config, name) if value is not None: + if name == 'max_tokens': + # OpenAI reasoning models reject the deprecated max_tokens + # field. Keep the explicit max_completion_tokens value when + # both knobs are supplied so the request remains valid. + if config.max_completion_tokens is not None: + continue + if _uses_max_completion_tokens(model) or config.reasoning_effort is not None: + body['max_completion_tokens'] = value + continue body[name] = value extras = config.model_extra if extras: @@ -382,7 +399,8 @@ async def _get_openai_request_config(self, request: ModelRequest) -> dict: ) if config.version: openai_config['model'] = config.version - openai_config.update(_openai_create_kwargs(config=config)) + effective_model = config.model or config.version or self._model + openai_config.update(_openai_create_kwargs(config=config, model=effective_model)) return openai_config async def _generate(self, request: ModelRequest) -> ModelResponse: diff --git a/py/packages/genkit-openai/tests/openai_model_test.py b/py/packages/genkit-openai/tests/openai_model_test.py index cc27c8c825..491ca20bbc 100644 --- a/py/packages/genkit-openai/tests/openai_model_test.py +++ b/py/packages/genkit-openai/tests/openai_model_test.py @@ -131,6 +131,51 @@ async def test_get_openai_config_peels_genkit_keys_and_passes_the_rest() -> None assert 'version' not in body +@pytest.mark.asyncio +async def test_get_openai_config_uses_max_completion_tokens_for_reasoning_models() -> None: + """Reasoning models reject the deprecated max_tokens request field.""" + model = OpenAIModel(model='gpt-6-astra', client=MagicMock()) + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=OpenAIConfig(max_tokens=32), + ) + + body = await model._get_openai_request_config(request) + + assert body['max_completion_tokens'] == 32 + assert 'max_tokens' not in body + + +@pytest.mark.asyncio +async def test_get_openai_config_keeps_max_tokens_for_legacy_models() -> None: + """Legacy OpenAI-compatible models continue to receive max_tokens.""" + model = OpenAIModel(model='gpt-4o', client=MagicMock()) + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=OpenAIConfig(max_tokens=32), + ) + + body = await model._get_openai_request_config(request) + + assert body['max_tokens'] == 32 + assert 'max_completion_tokens' not in body + + +@pytest.mark.asyncio +async def test_get_openai_config_prefers_explicit_max_completion_tokens() -> None: + """An explicit modern token limit wins when both fields are configured.""" + model = OpenAIModel(model='gpt-4o', client=MagicMock()) + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=OpenAIConfig(max_tokens=32, max_completion_tokens=64), + ) + + body = await model._get_openai_request_config(request) + + assert body['max_completion_tokens'] == 64 + assert 'max_tokens' not in body + + @pytest.mark.asyncio async def test_get_openai_config_model_field_overrides_version() -> None: """OpenAIConfig.model is the create() model id; it wins over version.""" From 1a26dcff88813c2c10f359e9d09c2f32aeab27dd Mon Sep 17 00:00:00 2001 From: Letian Liu Date: Thu, 10 Sep 2026 04:49:13 +0800 Subject: [PATCH 2/4] fix(py/plugins/openai): detect custom reasoning deployments --- .../src/genkit_openai/models/model.py | 11 ++++++++++- .../genkit-openai/tests/openai_model_test.py | 17 +++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/py/packages/genkit-openai/src/genkit_openai/models/model.py b/py/packages/genkit-openai/src/genkit_openai/models/model.py index 0158e6bba6..a288f7ca9e 100644 --- a/py/packages/genkit-openai/src/genkit_openai/models/model.py +++ b/py/packages/genkit-openai/src/genkit_openai/models/model.py @@ -61,7 +61,16 @@ def _uses_max_completion_tokens(model: str | None) -> bool: if not model: return False model_id = model.rsplit('/', 1)[-1].lower() - return model_id.startswith(('o1', 'o3', 'o4', 'gpt-5', 'gpt-6')) + # Fine-tuned OpenAI model ids are prefixed with ``ft:`` and custom + # deployment names may put the base model after an arbitrary prefix. + if model_id.startswith('ft:'): + parts = model_id.split(':') + if len(parts) > 1: + model_id = parts[1] + reasoning_prefixes = ('o1', 'o3', 'o4', 'gpt-5', 'gpt-6') + return model_id.startswith(reasoning_prefixes) or any( + f'-{prefix}' in model_id for prefix in reasoning_prefixes + ) def _openai_create_kwargs(*, config: OpenAIConfig, model: str | None = None) -> dict[str, Any]: diff --git a/py/packages/genkit-openai/tests/openai_model_test.py b/py/packages/genkit-openai/tests/openai_model_test.py index 491ca20bbc..4355e29a73 100644 --- a/py/packages/genkit-openai/tests/openai_model_test.py +++ b/py/packages/genkit-openai/tests/openai_model_test.py @@ -134,16 +134,17 @@ async def test_get_openai_config_peels_genkit_keys_and_passes_the_rest() -> None @pytest.mark.asyncio async def test_get_openai_config_uses_max_completion_tokens_for_reasoning_models() -> None: """Reasoning models reject the deprecated max_tokens request field.""" - model = OpenAIModel(model='gpt-6-astra', client=MagicMock()) - request = ModelRequest( - messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], - config=OpenAIConfig(max_tokens=32), - ) + for model_name in ('gpt-6-astra', 'ft:o1-mini:my-org:custom', 'my-o1-mini-deployment'): + model = OpenAIModel(model=model_name, client=MagicMock()) + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=OpenAIConfig(max_tokens=32), + ) - body = await model._get_openai_request_config(request) + body = await model._get_openai_request_config(request) - assert body['max_completion_tokens'] == 32 - assert 'max_tokens' not in body + assert body['max_completion_tokens'] == 32, f'Failed for model: {model_name}' + assert 'max_tokens' not in body, f'Failed for model: {model_name}' @pytest.mark.asyncio From a29a5e54aee37cc66f4173a2e70acbc32c8c7ad3 Mon Sep 17 00:00:00 2001 From: Letian Liu Date: Mon, 14 Sep 2026 17:25:34 +0800 Subject: [PATCH 3/4] style(py/plugins/openai): apply ruff preview formatting --- py/packages/genkit-openai/src/genkit_openai/models/model.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/py/packages/genkit-openai/src/genkit_openai/models/model.py b/py/packages/genkit-openai/src/genkit_openai/models/model.py index a288f7ca9e..93b3a4dcf6 100644 --- a/py/packages/genkit-openai/src/genkit_openai/models/model.py +++ b/py/packages/genkit-openai/src/genkit_openai/models/model.py @@ -68,9 +68,7 @@ def _uses_max_completion_tokens(model: str | None) -> bool: if len(parts) > 1: model_id = parts[1] reasoning_prefixes = ('o1', 'o3', 'o4', 'gpt-5', 'gpt-6') - return model_id.startswith(reasoning_prefixes) or any( - f'-{prefix}' in model_id for prefix in reasoning_prefixes - ) + return model_id.startswith(reasoning_prefixes) or any(f'-{prefix}' in model_id for prefix in reasoning_prefixes) def _openai_create_kwargs(*, config: OpenAIConfig, model: str | None = None) -> dict[str, Any]: From 4df4c376d788f585527adeae8114ef4f0d32ea9e Mon Sep 17 00:00:00 2001 From: Letian Liu Date: Tue, 15 Sep 2026 20:11:32 +0800 Subject: [PATCH 4/4] test(py/plugins/openai): cover reasoning effort token mapping --- .../src/genkit_openai/models/model.py | 2 ++ .../genkit-openai/tests/openai_model_test.py | 32 ++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/py/packages/genkit-openai/src/genkit_openai/models/model.py b/py/packages/genkit-openai/src/genkit_openai/models/model.py index 93b3a4dcf6..ed474be71d 100644 --- a/py/packages/genkit-openai/src/genkit_openai/models/model.py +++ b/py/packages/genkit-openai/src/genkit_openai/models/model.py @@ -79,6 +79,8 @@ def _openai_create_kwargs(*, config: OpenAIConfig, model: str | None = None) -> caller when ``OpenAIConfig.model`` is unset. Everything else, including extras, goes out under the Python field name. ``max_output_tokens`` is not mapped to ``max_tokens`` — that knob is ``max_tokens`` / ``maxTokens``. + For reasoning models, ``max_tokens`` is emitted as ``max_completion_tokens`` + because the OpenAI API rejects the deprecated field. """ body: dict[str, Any] = {} for name in type(config).model_fields: diff --git a/py/packages/genkit-openai/tests/openai_model_test.py b/py/packages/genkit-openai/tests/openai_model_test.py index 4355e29a73..7ec2f4fc42 100644 --- a/py/packages/genkit-openai/tests/openai_model_test.py +++ b/py/packages/genkit-openai/tests/openai_model_test.py @@ -25,7 +25,7 @@ from genkit_openai.models import OpenAIModel from genkit_openai.models.model import _usage_from_completion from genkit_openai.models.utils import strip_markdown_fences -from genkit_openai.typing import OpenAIConfig +from genkit_openai.typing import OpenAIConfig, ReasoningEffort from openai.types import CompletionUsage from openai.types.chat import ChatCompletionChunk from pydantic import BaseModel @@ -132,19 +132,29 @@ async def test_get_openai_config_peels_genkit_keys_and_passes_the_rest() -> None @pytest.mark.asyncio -async def test_get_openai_config_uses_max_completion_tokens_for_reasoning_models() -> None: +@pytest.mark.parametrize( + ('model_name', 'reasoning_effort'), + [ + ('gpt-6-astra', None), + ('ft:o1-mini:my-org:custom', None), + ('my-o1-mini-deployment', None), + ('my-deployment', ReasoningEffort.HIGH), + ], +) +async def test_get_openai_config_uses_max_completion_tokens_for_reasoning_models( + model_name: str, reasoning_effort: ReasoningEffort | None +) -> None: """Reasoning models reject the deprecated max_tokens request field.""" - for model_name in ('gpt-6-astra', 'ft:o1-mini:my-org:custom', 'my-o1-mini-deployment'): - model = OpenAIModel(model=model_name, client=MagicMock()) - request = ModelRequest( - messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], - config=OpenAIConfig(max_tokens=32), - ) + model = OpenAIModel(model=model_name, client=MagicMock()) + request = ModelRequest( + messages=[Message(role=Role.USER, content=[Part(root=TextPart(text='hi'))])], + config=OpenAIConfig(max_tokens=32, reasoning_effort=reasoning_effort), + ) - body = await model._get_openai_request_config(request) + body = await model._get_openai_request_config(request) - assert body['max_completion_tokens'] == 32, f'Failed for model: {model_name}' - assert 'max_tokens' not in body, f'Failed for model: {model_name}' + assert body['max_completion_tokens'] == 32 + assert 'max_tokens' not in body @pytest.mark.asyncio