fix: send supported reasoning parameters to gpt-5.1 and gpt-6 models

This commit is contained in:
Manuel Escalante
2026-10-26 18:35:47 +01:00
parent 55e830904d
commit 542ff12890
2 changed files with 39 additions and 15 deletions
@@ -5,6 +5,7 @@ Models are loaded dynamically from the API.
""" """
from typing import Optional, List from typing import Optional, List
import json import json
import re
import urllib.request import urllib.request
import urllib.error import urllib.error
from .base import AIProvider, AIProviderError from .base import AIProvider, AIProviderError
@@ -36,11 +37,11 @@ class OpenAIProvider(AIProvider):
] ]
# Recommended models for chat (in priority order) # Recommended models for chat (in priority order)
RECOMMENDED_PREFIXES = ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'] RECOMMENDED_PREFIXES = ['gpt-6-luna', 'gpt-5.6-luna', 'gpt-5.4-mini', 'gpt-6-sol']
@staticmethod @staticmethod
def _is_reasoning_model(model: str) -> bool: def _is_reasoning_model(model: str) -> bool:
"""True for OpenAI reasoning models (o-series + non-chat gpt-5+). """True for OpenAI reasoning models (o-series + non-chat gpt-5 and later).
These use a stricter API contract than chat models: These use a stricter API contract than chat models:
- Must use ``max_completion_tokens`` instead of ``max_tokens`` - Must use ``max_completion_tokens`` instead of ``max_tokens``
@@ -54,10 +55,23 @@ class OpenAIProvider(AIProvider):
# o1, o3, o4, o5 ... (o<digit>...) # o1, o3, o4, o5 ... (o<digit>...)
if len(m) >= 2 and m[0] == 'o' and m[1].isdigit(): if len(m) >= 2 and m[0] == 'o' and m[1].isdigit():
return True return True
# gpt-5, gpt-5-mini, gpt-5.1, gpt-5.2-pro ... EXCEPT *-chat-latest # gpt-5, gpt-5-mini, gpt-5.1, gpt-6-luna, gpt-6.1-sol ... EXCEPT *-chat-latest
if m.startswith('gpt-5') and '-chat' not in m: major = re.match(r'gpt-(\d+)', m)
return True return bool(major) and int(major.group(1)) >= 5 and '-chat' not in m
return False
@staticmethod
def _reasoning_effort(model: str) -> str:
"""Lowest reasoning_effort the model accepts.
gpt-5 / gpt-5-mini / gpt-5-nano (and the o-series) accept 'minimal'.
gpt-5.1 and later reject 'minimal' with HTTP 400, and gpt-6-astra /
gpt-6.1-sol also reject 'none', so 'low' is the value all of them
accept. In tests it spent 0-5 reasoning tokens on a notification.
"""
m = model.lower()
if re.match(r'gpt-5(?!\.)', m) or m.startswith('o'):
return 'minimal'
return 'low'
def list_models(self) -> List[str]: def list_models(self) -> List[str]:
"""List available models for chat completions. """List available models for chat completions.
@@ -189,22 +203,19 @@ class OpenAIProvider(AIProvider):
# apply model-specific defaults. # apply model-specific defaults.
if self.base_url: if self.base_url:
payload['max_tokens'] = max_tokens payload['max_tokens'] = max_tokens
# Reasoning models (o1/o3/o4/gpt-5*, excluding *-chat-latest) use a # Reasoning models (o1/o3/o4/gpt-5+, excluding *-chat-latest) use a
# different parameter contract: max_completion_tokens instead of # different parameter contract: max_completion_tokens instead of
# max_tokens, and no temperature field. Sending the classic chat # max_tokens, and no temperature field. Sending the classic chat
# parameters to them produces HTTP 400 Bad Request. # parameters to them produces HTTP 400 Bad Request.
# #
# They also spend output budget on internal reasoning by default, # They also spend output budget on internal reasoning by default,
# which empties the user-visible reply when max_tokens is small # which empties the user-visible reply when max_tokens is small
# (like the ~200 we use for notifications). reasoning_effort # (like the ~200 we use for notifications). The lowest
# 'minimal' keeps that internal reasoning to a minimum so the # reasoning_effort the model accepts keeps that internal reasoning
# entire budget is available for the translation, which is # to a minimum so the budget goes to the translation.
# exactly what this pipeline wants. OpenAI documents 'minimal',
# 'low', 'medium', 'high' — 'minimal' is the right setting for a
# straightforward translate+explain task.
elif self._is_reasoning_model(self.model): elif self._is_reasoning_model(self.model):
payload['max_completion_tokens'] = max_tokens payload['max_completion_tokens'] = max_tokens
payload['reasoning_effort'] = 'minimal' payload['reasoning_effort'] = self._reasoning_effort(self.model)
else: else:
payload['max_tokens'] = max_tokens payload['max_tokens'] = max_tokens
payload['temperature'] = 0.3 payload['temperature'] = 0.3
@@ -98,6 +98,19 @@ class OpenAICompatibleModelTests(unittest.TestCase):
self.assertNotIn("temperature", provider.captured_payload) self.assertNotIn("temperature", provider.captured_payload)
self.assertNotIn("max_tokens", provider.captured_payload) self.assertNotIn("max_tokens", provider.captured_payload)
def test_newer_reasoning_models_use_low_effort(self):
# gpt-5.1+ reject 'minimal'; gpt-6* were sent chat parameters before.
for model in ("gpt-5.6-luna", "gpt-6-luna", "gpt-6.1-sol"):
with self.subTest(model=model):
provider = CapturingOpenAIProvider(api_key="token", model=model)
self.assertEqual(provider.generate("system", "user", max_tokens=123), "ok")
self.assertEqual(provider.captured_payload["max_completion_tokens"], 123)
self.assertEqual(provider.captured_payload["reasoning_effort"], "low")
self.assertNotIn("temperature", provider.captured_payload)
self.assertNotIn("max_tokens", provider.captured_payload)
def test_runtime_ai_config_prefers_provider_specific_model(self): def test_runtime_ai_config_prefers_provider_specific_model(self):
manager = self._manager({ manager = self._manager({
"ai_enabled": "true", "ai_enabled": "true",