Дата публикации
ai_products

Как перейти на GPT-5.1 и не уронить прод: подробный гид по совместимости с GPT-4o

Что нового

Microsoft на площадке Azure OpenAI (Microsoft Foundry) фактически вводит два разных семейства моделей под общим брендом:

  • GPT‑4 / GPT‑4o / GPT‑4‑32k / GPT‑3.5‑turbo — «наследные» чат‑модели.
  • GPT‑5.x, o1, o3, o4 — «reasoning‑модели», которые тратят часть токенов на внутреннее рассуждение.

Главные технические изменения для продакшена:

  1. Новый протокол параметров для GPT‑5.x / o‑серии

    • Вместо max_tokens теперь используется max_completion_tokens.
    • Параметры temperature, top_p, presence_penalty, frequency_penalty больше не поддерживаются — запрос с ними вернётся с HTTP 400 Unsupported parameter.
    • Новые параметры управления рассуждением:
      • reasoning_effort: minimal | low | medium | high.
      • verbosity: low | medium | high (часто передаётся через extra_body).
  2. Общий бюджет токенов ответа

    • GPT‑5.1 тратит в 2–4 раза больше токенов на внутреннее «мышление», прежде чем вернуть первый токен ответа.
    • Старый лимит, который комфортно жил на GPT‑4o, теперь режет ответы:
      • Пример: лимит 4096 токенов, который спокойно держал SQL‑ответ на GPT‑4o, на GPT‑5.1 может обрывать ответ посередине токена.
    • Рекомендация из гайда:
      • умножать старые бюджеты примерно на 2,5×;
      • ввести минимальный порог, например 4096 токенов.
  3. Стоимость по токенам

    • Для GPT‑4 / GPT‑4o в лимит и биллинг обычно попадают только выходные токены.
    • Для GPT‑5.x / o‑серии в счёт идут и выходные, и reasoning‑токены.
  4. Поведение API и версии

    • Наследные модели: ожидаемый API‑путь с параметрами max_tokens, temperature, top_p и т.д.
    • Reasoning‑модели: отклоняют старые sampling‑параметры и stop (на многих деплойментах).
    • Рекомендуемые версии API:
      • Для GPT‑4 / GPT‑4o: 2024-12-01-preview или старше.
      • Для GPT‑5.x / o‑серии: 2025-03-01-preview или новее.
  5. Роль system / developer

    • В reasoning‑семействе появляется новая роль developer.
    • Старый system продолжает работать как алиас, но часть инструментов (например, LangChain) пока жёстко завязаны на system.
    • В совместимом слое это настраивается через переменную окружения OPENAI_USE_DEVELOPER_ROLE.
  6. Жёсткий breaking change по stop

    • Многие reasoning‑деплойменты возвращают 400, если в запросе есть stop.
    • LangChain‑цепочки (например, create_sql_query_chain) автоматически подставляют stop=["\nSQLResult:"], даже если вы этого нигде не писали.
    • В результате первый же запрос к GPT‑5.1 из старого кода падает с 400, хотя с GPT‑4o всё было зелёным.
  7. Готовый модуль совместимости Пост предлагает положить в проект один файл model_compat.py, который:

    • определяет, к какому семейству относится деплоймент (legacy vs reasoning);
    • собирает корректный набор аргументов для SDK OpenAI / Azure OpenAI;
    • масштабирует max_tokens для reasoning‑моделей;
    • аккуратно прокидывает reasoning_effort и verbosity;
    • даёт функцию выбора роли system/developer.

Отдельно идёт маленький файл langchain_compat.py с подклассом ReasoningSafeAzureChatOpenAI, который автоматически выбрасывает stop для reasoning‑деплойментов, но оставляет поведение бит‑в‑бит таким же для GPT‑4 / GPT‑3.5.

Как это работает

1. Два семейства моделей и детектор

Вся логика завязана на определение «семейства» по имени деплоймента или модели.

В model_compat.py задаются два набора регулярных выражений:

_REASONING_PATTERNS = (
    # gpt-5, gpt5, gpt-5.1, gpt_5, GPT 5, gpt5mini-prod-eu, ...
    re.compile(r"(?i)(^|[^a-z0-9])gpt[-_ ]?5(\.\d+)?([^0-9]|$)"),
    # o1, o3, o4, o1-mini, o3-preview ...
    re.compile(r"(?i)(^|[^a-z0-9])o[134](-mini|-preview)?([^a-z0-9]|$)"),
)

_LEGACY_PATTERNS = (
    re.compile(r"(?i)gpt[-_ ]?4o"),
    re.compile(r"(?i)gpt[-_ ]?4(?!\d)"),
    re.compile(r"(?i)gpt[-_ ]?4[-_ ]?32k"),
    re.compile(r"(?i)gpt[-_ ]?3\.?5"),
    re.compile(r"(?i)gpt[-_ ]?35"),
)

Функция get_model_family():

  • сначала смотрит на переменную окружения OPENAI_MODEL_FAMILY — это нужно, если деплоймент называется, например, prod-default и из имени нельзя понять семейство;
  • если оверрайда нет, прогоняет имя через паттерны:
    • если совпал _REASONING_PATTERNS — возвращает "reasoning";
    • если совпал _LEGACY_PATTERNS"legacy";
    • иначе — по умолчанию "legacy".

Важно: автор сознательно «ошибается в сторону legacy». Если reasoning‑деплоймент случайно классифицировать как legacy, вы получите 400 с Unsupported parameter и сразу увидите проблему. Если сделать наоборот, то часть нужных параметров тихо потеряется.

2. Управление рассуждением: reasoning_effort и verbosity

В модуле есть два хелпера, которые берут значения либо из аргументов, либо из переменных окружения:

_VALID_REASONING_EFFORT = {"minimal", "low", "medium", "high"}
_VALID_VERBOSITY = {"low", "medium", "high"}


def get_reasoning_effort(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_REASONING_EFFORT"),
        _VALID_REASONING_EFFORT,
    )


def get_verbosity(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_VERBOSITY"),
        _VALID_VERBOSITY,
    )

Функция _coerce_choice:

  • нормализует строку к lower‑case;
  • проверяет, входит ли значение в допустимый набор;
  • если нет — пишет logging.warning и возвращает None.

Так вы не сломаете прод из‑за опечатки в переменной окружения — параметр просто не уйдёт в запрос.

3. Масштабирование max_tokens для reasoning‑моделей

Ключевая часть — функция scale_max_tokens_for_reasoning():

def _reasoning_token_scale() -> float:
    try:
        scale = float(os.getenv("OPENAI_REASONING_TOKEN_SCALE", "2.5"))
    except (TypeError, ValueError):
        scale = 2.5
    return scale if scale > 0 else 1.0


def _reasoning_token_floor() -> int:
    try:
        floor = int(os.getenv("OPENAI_REASONING_TOKEN_FLOOR", "4096"))
    except (TypeError, ValueError):
        floor = 4096
    return floor if floor > 0 else 4096


def scale_max_tokens_for_reasoning(max_tokens: Optional[int]) -> Optional[int]:
    """Scale a legacy ``max_tokens`` budget up for reasoning models.

    ``None`` and ``-1`` ("no explicit cap") are passed through.
    """
    if max_tokens is None:
        return None
    if max_tokens == -1:
        return -1
    return max(int(round(max_tokens * _reasoning_token_scale())), _reasoning_token_floor())

Что происходит:

  • вы продолжаете в коде оперировать старым max_tokens;
  • для reasoning‑моделей он автоматически умножается на коэффициент из OPENAI_REASONING_TOKEN_SCALE (по умолчанию 2.5);
  • применяется нижний порог из OPENAI_REASONING_TOKEN_FLOOR (по умолчанию 4096 токенов);
  • если вы явно передали -1, это трактуется как «без явного лимита» и не трогается.

4. Сборка аргументов для SDK OpenAI / Azure OpenAI

Главная функция:

def build_openai_chat_kwargs(
    model: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    presence_penalty: Optional[float] = None,
    frequency_penalty: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
    extra: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``openai.OpenAI / AzureOpenAI .chat.completions.create``.

    Splat the result directly: ``client.chat.completions.create(**kwargs)``.
    Unsupported parameters are silently omitted for reasoning models; legacy
    deployments retain the historical behaviour.
    """
    family = get_model_family(model)
    kwargs: Dict[str, Any] = {"model": model}

    # ---- output budget ----
    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    # ---- sampling ----
    if family == "legacy":
        kwargs.update(_drop_none({
            "temperature": temperature,
            "top_p": top_p,
            "presence_penalty": presence_penalty,
            "frequency_penalty": frequency_penalty,
        }))
    else:
        for key, value in (
            ("temperature", temperature), ("top_p", top_p),
            ("presence_penalty", presence_penalty), ("frequency_penalty", frequency_penalty),
        ):
            if value is not None:
                logging.debug(
                    "Dropping unsupported parameter '%s' for reasoning model '%s'",
                    key, model,
                )

    # ---- reasoning controls ----
    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            # ``verbosity`` is not a top-level kwarg in openai-python <= 1.65.x;
            # route it via ``extra_body`` so it lands in the JSON without a
            # TypeError from the SDK.
            kwargs.setdefault("extra_body", {})["verbosity"] = verb

    # ---- caller-supplied extras (already filtered) ----
    if extra:
        for key, value in extra.items():
            if value is None:
                continue
            if family == "reasoning" and key in _SAMPLING_KEYS:
                continue
            kwargs[key] = value

    return kwargs

Что делает функция:

  • для legacy‑моделей:
    • передаёт max_tokens, temperature, top_p, presence_penalty, frequency_penalty как раньше;
  • для reasoning‑моделей:
    • заменяет max_tokens на max_completion_tokens с масштабированием;
    • тихо отбрасывает все sampling‑параметры, которые модель не понимает;
    • добавляет reasoning_effort и verbosity (через extra_body, чтобы SDK не упал из‑за неизвестного аргумента);
  • любые дополнительные параметры из extra проходят фильтрацию: если ключ попадает в список _SAMPLING_KEYS, он не уходит reasoning‑модели.

5. LangChain: отдельный билдер аргументов

Для LangChain‑классов (AzureChatOpenAI, ChatOpenAI) часть параметров передаётся не напрямую, а через model_kwargs. Отсюда вторая функция:

def build_langchain_chat_kwargs(
    deployment_name: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``langchain_openai.AzureChatOpenAI`` / ``ChatOpenAI``.

    Older ``langchain-openai`` releases don't expose ``max_completion_tokens``
    as a top-level kwarg, so we forward it through ``model_kwargs`` (which
    langchain passes straight to the SDK).
    """
    family = get_model_family(deployment_name)
    kwargs: Dict[str, Any] = {}
    model_kwargs: Dict[str, Any] = {}

    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            model_kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            model_kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            model_kwargs.setdefault("extra_body", {})["verbosity"] = verb
    else:
        if temperature is not None:
            kwargs["temperature"] = temperature
        if top_p is not None:
            kwargs["top_p"] = top_p

    if model_kwargs:
        kwargs["model_kwargs"] = model_kwargs
    return kwargs

Здесь логика та же, но адаптирована под интерфейс LangChain.

6. Роли system / developer

Функция get_system_role() даёт единое место, где вы решаете, какую роль использовать:

def get_system_role(model_or_deployment: Optional[str] = None) -> str:
    """Return ``"developer"`` for reasoning models when opted in, ``"system"`` otherwise.

    Defaulting to ``"system"`` preserves compatibility with LangChain prompt
    templates and SDK helpers that don't yet recognise the new role. Opt in
    with ``OPENAI_USE_DEVELOPER_ROLE=1`` once your stack supports it.
    """
    if not is_reasoning_model(model_or_deployment):
        return "system"
    raw = os.getenv("OPENAI_USE_DEVELOPER_ROLE", "")
    return "developer" if raw.strip().lower() in {"1", "true", "yes", "on"} else "system"

По умолчанию всё остаётся на system, чтобы не ломать существующие промпты и шаблоны. Как только стек готов к developer, вы включаете его флагом.

7. LangChain и скрытый stop

Главный «подводный камень»: многие цепочки LangChain сами добавляют stop. Например, langchain.chains.sql_database.query.create_sql_query_chain всегда вызывает llm.bind(stop=["\nSQLResult:"]).

GPT‑5.1 и другие reasoning‑модели на Azure OpenAI отвечают на такой запрос так:

openai.BadRequestError: Error code: 400 - {'error': {
  'message': "Unsupported parameter: 'stop' is not supported with this model.",
  'type': 'invalid_request_error',
  'param': 'stop',
}}

Вы не можете из внешнего кода запретить этой цепочке добавлять stop. Поэтому автор предлагает аккуратный шорткат — подкласс ReasoningSafeAzureChatOpenAI:

"""LangChain-side compatibility shim for reasoning-class deployments."""

from __future__ import annotations

from typing import Any, List, Optional

from langchain_core.callbacks.manager import (
    AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun,
)
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatResult
from langchain_openai import AzureChatOpenAI   # use ChatOpenAI for non-Azure

from model_compat import is_reasoning_model


class ReasoningSafeAzureChatOpenAI(AzureChatOpenAI):
    """``AzureChatOpenAI`` variant that hides parameters reasoning models reject.

    Reasoning models (GPT-5.x, o1/o3/o4) return HTTP 400 when a request
    payload carries ``stop``. LangChain's SQL helpers unconditionally bind it,
    so the unsupported parameter reaches the SDK regardless of how the caller
    configured the LLM. This subclass strips ``stop`` for reasoning
    deployments while forwarding it unchanged for legacy GPT-4 / GPT-3.5
    deployments - the behaviour is byte-identical to upstream LangChain
    for those models.
    """

    def _deployment_id(self) -> str:
        # ``langchain-openai`` >= 0.2 exposes ``azure_deployment``; older
        # releases use ``deployment_name``. Either may be set by the caller.
        return (
            getattr(self, "azure_deployment", None)
            or getattr(self, "deployment_name", None)
            or ""
        )

    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[CallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)

    async def _agenerate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)

Класс:

  • для GPT‑4 / GPT‑3.5 ведёт себя точно так же, как обычный AzureChatOpenAI;
  • для GPT‑5.x / o‑серии обнуляет stop перед вызовом родительской реализации.

Этого достаточно, чтобы create_sql_query_chain, SQLDatabaseChain и RAG‑хелперы, построенные на ChatOpenAI, начали работать с GPT‑5.1 без переписывания промптов и цепочек.

Что это значит для вас

Если вы держите продакшен‑сервис на Azure OpenAI или OpenAI API и хотите подключить GPT‑5.x / o‑серию, но при этом не выключать GPT‑4o, для вас здесь несколько прямых выводов.

1. Нельзя просто заменить имя модели в конфиге

Сценарий «поменяли engine="gpt-4o" на "gpt-5.1" и поехали» больше не работает.

  • Первый же запрос к GPT‑5.1 вернёт HTTP 400, потому что:
    • в запросе есть temperature, top_p или штрафы;
    • LangChain незаметно добавил stop;
    • вы продолжаете слать max_tokens вместо max_completion_tokens.

Обратная сторона тоже жёсткая:

  • Если вы настроите код под GPT‑5.1 (новые параметры, без sampling‑контролей), запросы к старым GPT‑4 деплойментам начнут падать*, потому что те не знают reasoning_effort и verbosity.

2. Не плодите ветки кода под каждую модель

Интуитивное решение — сделать два пути:

  • один код для GPT‑4;
  • другой для GPT‑5.

Через полгода таких веток станет десяток, и вы начнёте забывать, где какой параметр меняли.

Гайд предлагает другой подход:

  • одна функция, которая определяет семейство (legacy / reasoning);
  • одна функция‑билдер, которая собирает валидный kwargs для этого семейства;
  • все вызовы SDK, LangChain и raw HTTP сходятся в этот билдер.

Когда вы окончательно выведите GPT‑4 из продакшена, вы меняете один файл, а не 50 мест по коду.

3. Пересчитайте лимиты токенов

Если вы сейчас держите жёсткие лимиты max_tokens для GPT‑4o, при переходе на GPT‑5.1:

  • увеличьте их примерно в 2,5 раза;
  • введите минимальный порог 4096;
  • закладывайте, что в счёт идут и reasoning‑токены.

Иначе GPT‑5.1 будет обрывать ответы, особенно в длинных задачах: SQL, сложная аналитика, генерация кода.

4. Перепроверьте LangChain‑цепочки

Если вы используете:

  • create_sql_query_chain;
  • SQLDatabaseChain;
  • RAG‑цепочки на ChatOpenAI;

скорее всего, где‑то внутри LangChain уже живёт stop, о котором вы не знаете.

Решение из гайда:

  • заменить AzureChatOpenAI на ReasoningSafeAzureChatOpenAI (или аналог для ChatOpenAI);
  • генерировать llm_kwargs через build_langchain_chat_kwargs().

Это минимальное изменение, которое возвращает зелёные тесты и не требует правок в промптах или цепочках.

5. Где GPT‑5.x / o‑серия особенно полезны

С учётом того, что reasoning‑модели тратят больше токенов на внутреннее рассуждение, они особенно полезны в задачах, где важна цепочка логики:

  • сложные SQL‑запросы и аналитика по базе данных;
  • многошаговые бизнес‑правила, где GPT‑4o часто «срезает углы»;
  • генерация кода и ревью с длинным контекстом, где модель должна «подумать» перед ответом.

Но есть и минусы:

  • больший расход токенов за счёт reasoning‑части;
  • жёсткие требования к протоколу запросов (нельзя просто «как раньше, только новую модель»);
  • необходимость пройтись по обвязке — SDK, LangChain, сырые HTTP‑клиенты.

6. Где лучше пока остаться на GPT‑4o

Если задача:

  • простая генерация текста;
  • короткие ответы без сложной логики;
  • критичен бюджет токенов;

можно оставить GPT‑4o как «рабочую лошадку», а GPT‑5.1 подключать точечно там, где нужен выигрыш в качестве рассуждений.

7. Доступность и инфраструктура

Речь идёт о Azure OpenAI в Microsoft Foundry и официальном OpenAI API. Для работы из России чаще всего потребуется VPN и инфраструктура за пределами страны. Это нужно учитывать при планировании продакшена и выборе региона деплоймента.

Место на рынке

Гайд сфокусирован на миграции внутри линейки OpenAI: GPT‑4 / GPT‑4o → GPT‑5.1 / o‑серия. Внутри этой экосистемы:

  • GPT‑5.1 и o‑серия:

    • дают более сложное рассуждение за счёт внутреннего reasoning;
    • потребляют 2–4× больше токенов на «думание» в тех же задачах;
    • требуют новых параметров (max_completion_tokens, reasoning_effort, verbosity).
  • GPT‑4 / GPT‑4o:

    • проще в интеграции, так как используют старый протокол (max_tokens, temperature, top_p и т.д.);
    • дешевле по токенам в сценариях, где reasoning‑запас не нужен;
    • безболезненно работают с существующими LangChain‑цепочками.

Чётких чисел по скорости, ценам за 1K токенов или сравнению с другими вендорами (например, Claude) в материале нет. Основной акцент — на том, как не сломать прод при одновременной работе GPT‑4o и GPT‑5.1.

Установка

Специальной установки нет: вы продолжаете использовать стандартные SDK OpenAI / Azure OpenAI и LangChain. Всё, что нужно — добавить в проект два файла: model_compat.py и langchain_compat.py.

Ниже — полный код из гайда.

model_compat.py

"""
Model compatibility helper for GPT-5.x with GPT-4 backward compatibility.

This module centralises the parameter translation needed to talk to the
"reasoning" generation of OpenAI / Azure OpenAI models (GPT-5, GPT-5.1,
""""o1, o3, o4) while keeping older deployments (gpt-4, gpt-4o, gpt-4-32k,
gpt-3.5-turbo, etc.) working unchanged.
"""

from __future__ import annotations

import logging
import os
import re
from typing import Any, Dict, Iterable, Mapping, Optional


# ---------------------------------------------------------------------------
# Family detection
# ---------------------------------------------------------------------------

_REASONING_PATTERNS = (
    # gpt-5, gpt5, gpt-5.1, gpt_5, GPT 5, gpt5mini-prod-eu, ...
    re.compile(r"(?i)(^|[^a-z0-9])gpt[-_ ]?5(\.\d+)?([^0-9]|$)"),
    # o1, o3, o4, o1-mini, o3-preview ...
    re.compile(r"(?i)(^|[^a-z0-9])o[134](-mini|-preview)?([^a-z0-9]|$)"),
)

_LEGACY_PATTERNS = (
    re.compile(r"(?i)gpt[-_ ]?4o"),
    re.compile(r"(?i)gpt[-_ ]?4(?!\d)"),
    re.compile(r"(?i)gpt[-_ ]?4[-_ ]?32k"),
    re.compile(r"(?i)gpt[-_ ]?3\.?5"),
    re.compile(r"(?i)gpt[-_ ]?35"),
)


def get_model_family(model_or_deployment: Optional[str]) -> str:
    """Return ``"reasoning"`` for GPT-5.x / o-series, ``"legacy"`` otherwise.

    Honours an ``OPENAI_MODEL_FAMILY`` env-var override for deployments whose
    user-defined name does not embed the model family (e.g. ``prod-default``).
    """
    override = (os.getenv("OPENAI_MODEL_FAMILY") or "").strip().lower()
    if override in {"reasoning", "gpt-5", "gpt5", "gpt-5.1", "o-series", "o1", "o3"}:
        return "reasoning"
    if override in {"legacy", "gpt-4", "gpt4", "gpt-3.5", "gpt35", "chat"}:
        return "legacy"

    name = (model_or_deployment or "").strip()
    if not name:
        # Fail closed: when we don't know, assume legacy so old code keeps
        # working. Misclassifying a reasoning deployment as legacy fails fast
        # with a clear "Unsupported parameter" 400; the reverse silently
        # drops parameters the caller expected.
        return "legacy"

    for pat in _REASONING_PATTERNS:
        if pat.search(name):
            return "reasoning"
    for pat in _LEGACY_PATTERNS:
        if pat.search(name):
            return "legacy"
    return "legacy"


def is_reasoning_model(model_or_deployment: Optional[str]) -> bool:
    return get_model_family(model_or_deployment) == "reasoning"


# ---------------------------------------------------------------------------
# Reasoning controls
# ---------------------------------------------------------------------------

_VALID_REASONING_EFFORT = {"minimal", "low", "medium", "high"}
_VALID_VERBOSITY = {"low", "medium", "high"}


def _coerce_choice(raw: Optional[str], valid: Iterable[str]) -> Optional[str]:
    if raw is None:
        return None
    value = str(raw).strip().lower()
    if not value:
        return None
    if value not in set(valid):
        logging.warning(
            "Ignoring unsupported value '%s'; expected one of %s",
            raw, sorted(valid),
        )
        return None
    return value


def get_reasoning_effort(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_REASONING_EFFORT"),
        _VALID_REASONING_EFFORT,
    )


def get_verbosity(override: Optional[str] = None) -> Optional[str]:
    return _coerce_choice(
        override if override is not None else os.getenv("OPENAI_VERBOSITY"),
        _VALID_VERBOSITY,
    )


# ---------------------------------------------------------------------------
# max_completion_tokens scaling
# ---------------------------------------------------------------------------

def _reasoning_token_scale() -> float:
    """Multiplier applied to legacy ``max_tokens`` when targeting a reasoning model."""
    try:
        scale = float(os.getenv("OPENAI_REASONING_TOKEN_SCALE", "2.5"))
    except (TypeError, ValueError):
        scale = 2.5
    return scale if scale > 0 else 1.0


def _reasoning_token_floor() -> int:
    try:
        floor = int(os.getenv("OPENAI_REASONING_TOKEN_FLOOR", "4096"))
    except (TypeError, ValueError):
        floor = 4096
    return floor if floor > 0 else 4096


def scale_max_tokens_for_reasoning(max_tokens: Optional[int]) -> Optional[int]:
    """Scale a legacy ``max_tokens`` budget up for reasoning models.

    ``None`` and ``-1`` ("no explicit cap") are passed through.
    """
    if max_tokens is None:
        return None
    if max_tokens == -1:
        return -1
    return max(int(round(max_tokens * _reasoning_token_scale())), _reasoning_token_floor())


# ---------------------------------------------------------------------------
# Kwargs builders
# ---------------------------------------------------------------------------

_SAMPLING_KEYS = ("temperature", "top_p", "presence_penalty", "frequency_penalty")


def _drop_none(mapping: Mapping[str, Any]) -> Dict[str, Any]:
    return {k: v for k, v in mapping.items() if v is not None}


def build_openai_chat_kwargs(
    model: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    presence_penalty: Optional[float] = None,
    frequency_penalty: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
    extra: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``openai.OpenAI / AzureOpenAI .chat.completions.create``.

    Splat the result directly: ``client.chat.completions.create(**kwargs)``.
    Unsupported parameters are silently omitted for reasoning models; legacy
    deployments retain the historical behaviour.
    """
    family = get_model_family(model)
    kwargs: Dict[str, Any] = {"model": model}

    # ---- output budget ----
    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    # ---- sampling ----
    if family == "legacy":
        kwargs.update(_drop_none({
            "temperature": temperature,
            "top_p": top_p,
            "presence_penalty": presence_penalty,
            "frequency_penalty": frequency_penalty,
        }))
    else:
        for key, value in (
            ("temperature", temperature), ("top_p", top_p),
            ("presence_penalty", presence_penalty), ("frequency_penalty", frequency_penalty),
        ):
            if value is not None:
                logging.debug(
                    "Dropping unsupported parameter '%s' for reasoning model '%s'",
                    key, model,
                )

    # ---- reasoning controls ----
    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            # ``verbosity`` is not a top-level kwarg in openai-python <= 1.65.x;
            # route it via ``extra_body`` so it lands in the JSON without a
            # TypeError from the SDK.
            kwargs.setdefault("extra_body", {})["verbosity"] = verb

    # ---- caller-supplied extras (already filtered) ----
    if extra:
        for key, value in extra.items():
            if value is None:
                continue
            if family == "reasoning" and key in _SAMPLING_KEYS:
                continue
            kwargs[key] = value

    return kwargs


def build_langchain_chat_kwargs(
    deployment_name: str,
    *,
    max_tokens: Optional[int] = None,
    temperature: Optional[float] = None,
    top_p: Optional[float] = None,
    reasoning_effort: Optional[str] = None,
    verbosity: Optional[str] = None,
) -> Dict[str, Any]:
    """Build kwargs for ``langchain_openai.AzureChatOpenAI`` / ``ChatOpenAI``.

    Older ``langchain-openai`` releases don't expose ``max_completion_tokens``
    as a top-level kwarg, so we forward it through ``model_kwargs`` (which
    langchain passes straight to the SDK).
    """
    family = get_model_family(deployment_name)
    kwargs: Dict[str, Any] = {}
    model_kwargs: Dict[str, Any] = {}

    if max_tokens is not None and max_tokens != -1:
        if family == "reasoning":
            model_kwargs["max_completion_tokens"] = scale_max_tokens_for_reasoning(int(max_tokens))
        else:
            kwargs["max_tokens"] = int(max_tokens)

    if family == "reasoning":
        effort = get_reasoning_effort(reasoning_effort)
        if effort is not None:
            model_kwargs["reasoning_effort"] = effort
        verb = get_verbosity(verbosity)
        if verb is not None:
            model_kwargs.setdefault("extra_body", {})["verbosity"] = verb
    else:
        if temperature is not None:
            kwargs["temperature"] = temperature
        if top_p is not None:
            kwargs["top_p"] = top_p

    if model_kwargs:
        kwargs["model_kwargs"] = model_kwargs
    return kwargs


def get_system_role(model_or_deployment: Optional[str] = None) -> str:
    """Return ``"developer"`` for reasoning models when opted in, ``"system"`` otherwise.

    Defaulting to ``"system"`` preserves compatibility with LangChain prompt
    templates and SDK helpers that don't yet recognise the new role. Opt in
    with ``OPENAI_USE_DEVELOPER_ROLE=1`` once your stack supports it.
    """
    if not is_reasoning_model(model_or_deployment):
        return "system"
    raw = os.getenv("OPENAI_USE_DEVELOPER_ROLE", "")
    return "developer" if raw.strip().lower() in {"1", "true", "yes", "on"} else "system"

langchain_compat.py

"""LangChain-side compatibility shim for reasoning-class deployments."""

from __future__ import annotations

from typing import Any, List, Optional

from langchain_core.callbacks.manager import (
    AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun,
)
from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatResult
from langchain_openai import AzureChatOpenAI   # use ChatOpenAI for non-Azure

from model_compat import is_reasoning_model


class ReasoningSafeAzureChatOpenAI(AzureChatOpenAI):
    """``AzureChatOpenAI`` variant that hides parameters reasoning models reject.

    Reasoning models (GPT-5.x, o1/o3/o4) return HTTP 400 when a request
    payload carries ``stop``. LangChain's SQL helpers unconditionally bind it,
    so the unsupported parameter reaches the SDK regardless of how the caller
    configured the LLM. This subclass strips ``stop`` for reasoning
    deployments while forwarding it unchanged for legacy GPT-4 / GPT-3.5
    deployments - the behaviour is byte-identical to upstream LangChain
    for those models.
    """

    def _deployment_id(self) -> str:
        # ``langchain-openai`` >= 0.2 exposes ``azure_deployment``; older
        # releases use ``deployment_name``. Either may be set by the caller.
        return (
            getattr(self, "azure_deployment", None)
            or getattr(self, "deployment_name", None)
            or ""
        )

    def _generate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[CallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)

    async def _agenerate(
        self,
        messages: List[BaseMessage],
        stop: Optional[List[str]] = None,
        run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
        **kwargs: Any,
    ) -> ChatResult:
        if is_reasoning_model(self._deployment_id()):
            stop = None
        return await super()._agenerate(messages, stop=stop, run_manager=run_manager, **kwargs)

Как запустить

1. Прямой вызов SDK Azure OpenAI / OpenAI

Пример для Azure OpenAI:

from openai import AzureOpenAI
from model_compat import build_openai_chat_kwargs

client = AzureOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    api_version=os.environ["OPENAI_API_VERSION"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
)

kwargs = build_openai_chat_kwargs(
    model=os.environ["OPENAI_ENGINE"],
    max_tokens=4096,           # автоматически станет max_completion_tokens для GPT-5
    temperature=0.2,           # автоматически выкинется для GPT-5
    reasoning_effort="low",    # автоматически выкинется для GPT-4
)
response = client.chat.completions.create(
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": user_input},
    ],
    **kwargs,
)

Один и тот же вызов корректно работает с:

  • gpt-5.1;
  • gpt-4o;
  • gpt-4-32k;
  • o3-mini;
  • любым будущим деплойментом, в имени которого зашито семейство.

Если деплоймент называется абстрактно (prod-default), вы можете принудительно указать семейство через OPENAI_MODEL_FAMILY.

2. Raw HTTP‑вызовы к Azure OpenAI

Если часть кода обходит SDK и шлёт HTTP‑запросы напрямую, тот же билдер можно использовать и там:

import json
import requests
from model_compat import build_openai_chat_kwargs, get_system_role

deployment = os.environ["OPENAI_ENGINE"]
api_version = os.environ["OPENAI_API_VERSION"]
endpoint = (
    f"{os.environ['AZURE_OPENAI_ENDPOINT']}/openai/deployments/{deployment}"
    f"/chat/completions?api-version={api_version}"
)

payload = {
    "messages": [
        {"role": get_system_role(deployment), "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ],
}
# Splat the kwargs into the payload, then strip the SDK-only ``model`` key.
payload.update(build_openai_chat_kwargs(
    model=deployment,
    max_tokens=800,
    temperature=0.7,
    top_p=0.95,
    reasoning_effort="low",
))
payload.pop("model", None)        # ``model`` is encoded in the URL for Azure
payload.pop("extra_body", None)   # already on the payload root

resp = requests.post(
    endpoint,
    headers={"Content-Type": "application/json", "api-key": api_key},
    data=json.dumps(payload),
    timeout=60,
)
resp.raise_for_status()

Тот же подход: один билдер, который знает, с каким семейством вы говорите.

3. LangChain + Azure OpenAI

Пример инициализации LLM для LangChain:

from langchain_compat import ReasoningSafeAzureChatOpenAI
from model_compat import build_langchain_chat_kwargs

llm_kwargs = build_langchain_chat_kwargs(
    deployment_name=os.environ["OPENAI_ENGINE"],
    max_tokens=6000,
    temperature=0,
    reasoning_effort="low",
)
llm = ReasoningSafeAzureChatOpenAI(
    azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
    azure_deployment=os.environ["OPENAI_ENGINE"],
    openai_api_version=os.environ["OPENAI_API_VERSION"],
    api_key=os.environ["AZURE_OPENAI_API_KEY"],
    **llm_kwargs,
)

После этой замены:

  • create_sql_query_chain;
  • SQLDatabaseChain;
  • RAG‑цепочки на ChatOpenAI;

начинают корректно работать с GPT‑5.1 и reasoning‑деплойментами, не ломая существующий код под GPT‑4o.


Читайте также