fix(models): route gpt-6 tool calls through the OpenAI Responses API by default (#6863)

* fix(models): route gpt-6 tool calls through the OpenAI Responses API by default

The locked langchain-openai==1.2.1 routes gpt-6 models configured with
langchain_openai:ChatOpenAI through /v1/chat/completions, where OpenAI
rejects function tools for the gpt-6 family with an HTTP 400. Upstream
only added the gpt-6 + tools -> Responses API branch in 1.7.x, so
backport that routing decision at the model factory instead of bumping
the dependency (large uv.lock churn, wider regression surface).

_apply_openai_responses_api_default() injects use_responses_api: true +
output_version: responses/v1 for BaseChatOpenAI subclasses whose model
starts with gpt-6, unless the operator configured an explicit value.

Fixes #6841

* fix(models): narrow gpt-6 Responses API default per review

Addresses review findings on #6863:

- Custom endpoints (base_url/openai_api_base) are never touched, so
  OpenAI-compatible gateways/proxies (LiteLLM, vLLM, one-api) keep their
  chat-completions routing instead of being silently re-pointed at /responses.
- AzureChatOpenAI is skipped too (MRO-based, so env-configured Azure and any
  subclass are covered): Azure's Responses API requires api-version >=
  2025-04-01-preview.
- An explicit use_responses_api still always wins; a lone explicit
  output_version is preserved while use_responses_api: true is still injected,
  so gpt-6 no longer silently falls back to /v1/chat/completions.
- The default injection is logged so operators can trace the /responses switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(models): treat env-var-wired gateways as custom endpoints for gpt-6

Addresses the remaining review findings on #6863:

- A gateway wired purely via OPENAI_BASE_URL/OPENAI_API_BASE (the openai SDK
  fallback when no base_url is passed) is now treated like an explicit
  endpoint, so a gpt-6 model behind an env-var-wired gateway is not silently
  re-pointed at <gateway>/responses. The known api_base-subclass blind spot is
  documented in the comment block.

- Regression tests pin the env-var endpoint gate and the explicit-wins
  precedence (an explicit use_responses_api still wins on a custom endpoint).

* test(models): isolate Responses routing from endpoint environment

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
万家良 2026-10-15 09:53:19 +08:00 committed by GitHub
parent e02d5e2fc3
commit 11979fdb57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 323 additions and 0 deletions

View File

@ -41,6 +41,7 @@ while handing off to the wait queue.
- Config values starting with `$` resolved as environment variables
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
- Optional `models[].request_admission` attaches a process-shared `BaseRateLimiter` at the model factory. Identical explicit groups share one FIFO across model instances, threads and event loops; implicit groups use the configured model name. Policies are immutable once registered and conflicting settings fail construction. A monotonic minimum interval spaces requests without idle-time burst credit; bounded waiters poll without occupying executor threads and unregister in `finally`. The factory strips the policy from provider kwargs and sets exposed SDK `max_retries=0` so middleware retries re-enter admission. This limits model invocations, not tokens or a distributed provider account; custom providers bypassing BaseChatModel hooks are outside the contract. Tests: `test_model_request_admission.py` and `test_model_request_admission_fifo_atomic.py`.
- `_apply_openai_responses_api_default` routes the gpt-6 family through the OpenAI Responses API by default (`use_responses_api: true` + `output_version: responses/v1`), because the locked `langchain-openai==1.2.1` lacks a gpt-6 routing branch and OpenAI rejects function tools for gpt-6 on `/v1/chat/completions`. Gated on `issubclass(model_class, BaseChatOpenAI)` and a `gpt-6`-prefixed `model`. An explicit `use_responses_api` always wins; custom endpoints (`base_url`/`openai_api_base` or a set `OPENAI_BASE_URL`/`OPENAI_API_BASE` environment variable) and `AzureChatOpenAI` are skipped (they may lack `/responses`); an explicit `output_version` alone is preserved while `use_responses_api: true` is still injected; injection is logged. Regression cases live in `tests/test_model_factory.py` (issue #6841).
### Reasoning Capability Contract (`packages/harness/deerflow/models/reasoning.py`)

View File

@ -1,5 +1,6 @@
import copy
import logging
import os
from langchain.chat_models import BaseChatModel
from langchain_openai.chat_models.base import BaseChatOpenAI
@ -429,6 +430,99 @@ def _apply_stream_chunk_timeout_default(model_class: type, model_settings_from_c
model_settings_from_config["stream_chunk_timeout"] = _DEFAULT_STREAM_CHUNK_TIMEOUT_SECONDS
# gpt-6 family must use the OpenAI Responses API when tools are attached.
#
# The locked ``langchain-openai==1.2.1`` still routes tool use through
# ``/v1/chat/completions`` for every model that is not in its hard-coded
# ``_RESPONSES_API_ONLY_PREFIXES`` tuple (gpt-5-pro style names only). OpenAI
# rejects function tools for the gpt-6 family on that endpoint ("Function tools
# with reasoning_effort are not supported for gpt-6.<x> in /v1/chat/completions"),
# so a gpt-6 model configured with ``langchain_openai:ChatOpenAI`` plus any tool
# fails every tool call with a 400. Newer langchain-openai (1.7.1+) added a
# ``gpt-6 + tools`` → Responses API branch; backport that behaviour here so the
# pinned dependency does not silently break the whole gpt-6 family.
#
# The model factory creates the client before any tool is bound, so a
# "gpt-6 + tools" check is not expressible at this point — the injection is
# necessarily broader than upstream's bind-time routing. Two gates keep that
# blast radius small:
#
# * **Custom endpoints are never touched.** ``_normalize_openai_base_url`` ran
# just above, so an OpenAI-compatible gateway/proxy configured via
# ``base_url``/``openai_api_base`` already lands in ``model_settings_from_config``;
# those deployments (LiteLLM, vLLM, one-api, …) often lack ``/responses`` and
# must not be silently re-pointed. ``AzureChatOpenAI`` is skipped too: it
# subclasses ``BaseChatOpenAI``, and Azure's Responses API additionally
# requires ``api-version >= 2025-04-01-preview``. A gateway wired purely via
# the ``OPENAI_BASE_URL``/``OPENAI_API_BASE`` environment variables (the openai
# SDK's fallback when no ``base_url`` is passed) is also treated as a custom
# endpoint. Known blind spot: a ``BaseChatOpenAI`` subclass that declares its
# own ``api_base`` field (ChatDeepSeek-style) skips
# ``_normalize_openai_base_url``, so its ``api_base`` never becomes ``base_url``
# and is invisible to this gate.
# * **The injection is logged**, so an operator can trace why their model began
# calling ``/responses`` even before they diagnose a failure.
#
# Behaviour:
#
# * An explicit ``use_responses_api`` always wins (true or false) — it is the
# operator's routing decision, even on a custom endpoint or Azure.
# * Otherwise a ``BaseChatOpenAI`` subclass whose ``model`` starts with ``gpt-6``
# gets ``use_responses_api: true`` + ``output_version: responses/v1`` injected,
# unless the model targets a custom endpoint (``base_url``/``openai_api_base``,
# or a set ``OPENAI_BASE_URL``/``OPENAI_API_BASE`` env var) or is an
# ``AzureChatOpenAI``.
# * An explicit ``output_version`` alone (without ``use_responses_api``) implies
# the operator expects Responses formatting: their value is preserved and
# ``use_responses_api: true`` is still injected, so the issue #6841 400s do not
# silently reappear.
# * Any other model family or client (e.g. ChatAnthropic) is left untouched.
def _apply_openai_responses_api_default(model_class: type, model_settings_from_config: dict) -> None:
if not issubclass(model_class, BaseChatOpenAI):
return
model_name = model_settings_from_config.get("model")
if not isinstance(model_name, str) or not model_name.lower().startswith("gpt-6"):
return
# An explicit routing choice always wins, even on a custom endpoint or Azure.
if "use_responses_api" in model_settings_from_config:
return
# A custom endpoint means the operator already owns routing: OpenAI-compatible
# gateways/proxies (LiteLLM, vLLM, one-api) configured via base_url /
# openai_api_base (canonicalized just above by _normalize_openai_base_url),
# and Azure deployments, rarely implement /responses (Azure's additionally
# needs api-version >= 2025-04-01-preview). Leave them untouched. The Azure
# check walks the MRO so it also covers env-configured Azure deployments
# (which leave azure_endpoint out of the model settings) and any subclass.
if any(getattr(base, "__name__", "") == "AzureChatOpenAI" for base in model_class.__mro__):
return
if any(key in model_settings_from_config for key in ("base_url", "openai_api_base", "azure_endpoint")):
return
# A gateway can also be wired purely through the environment: the openai SDK
# falls back to OPENAI_BASE_URL/OPENAI_API_BASE when no base_url is passed,
# so a gpt-6 model behind an env-var-wired gateway would otherwise be
# re-pointed at <gateway>/responses — the exact regression the endpoint gate
# exists to avoid. Treat a set env var like an explicit endpoint.
if os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE"):
return
if "output_version" in model_settings_from_config:
# Operator pinned an output version — implying Responses formatting —
# without enabling routing. Honor the value but still turn the default on,
# otherwise gpt-6 stays on /v1/chat/completions and re-exposes #6841.
model_settings_from_config["use_responses_api"] = True
logger.debug(
"gpt-6 model '%s': enabling OpenAI Responses API routing (use_responses_api=true) with operator output_version=%r",
model_name,
model_settings_from_config["output_version"],
)
return
model_settings_from_config["use_responses_api"] = True
model_settings_from_config["output_version"] = "responses/v1"
logger.info(
"gpt-6 model '%s': routing through the OpenAI Responses API (use_responses_api=true, output_version=responses/v1) because the pinned langchain-openai cannot call gpt-6 tools on /v1/chat/completions",
model_name,
)
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, attach_tracing: bool = True, model_overrides: dict | None = None, **kwargs) -> BaseChatModel:
"""Create a chat model instance from the config.
@ -547,6 +641,8 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
# heuristics (stream_usage default below / stream_chunk_timeout) see the canonical endpoint key.
_normalize_openai_base_url(model_class, model_settings_from_config)
_apply_stream_chunk_timeout_default(model_class, model_settings_from_config)
# gpt-6 + tools must route through the Responses API (see the helper docstring).
_apply_openai_responses_api_default(model_class, model_settings_from_config)
# For Codex Responses API models: map thinking mode to reasoning_effort
if is_codex_model:

View File

@ -2552,3 +2552,229 @@ def test_legacy_profiles_are_untouched_by_the_contract_machinery(monkeypatch):
assert captured["extra_body"] == {"thinking": {"type": "disabled"}}
assert captured["reasoning_effort"] == "minimal"
# ---------------------------------------------------------------------------
# gpt-6 → OpenAI Responses API default routing (issue #6841)
# ---------------------------------------------------------------------------
def test_gpt6_model_routes_through_responses_api_by_default(monkeypatch):
"""A gpt-6 model configured with ``langchain_openai:ChatOpenAI`` must receive
``use_responses_api=true`` + ``output_version=responses/v1`` by default, so
tool use goes through ``/v1/responses`` instead of the chat-completions
endpoint that OpenAI rejects for the gpt-6 family (locked
langchain-openai==1.2.1 has no gpt-6 routing branch).
"""
from langchain_openai import ChatOpenAI
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
model = _make_model("gpt-6.1-sol", use="langchain_openai:ChatOpenAI")
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6.1-sol")
assert captured.get("use_responses_api") is True
assert captured.get("output_version") == "responses/v1"
def test_gpt6_explicit_responses_config_not_overridden(monkeypatch):
"""If the operator already set ``use_responses_api``/``output_version`` on a
gpt-6 model, the factory must not clobber the explicit value.
"""
from langchain_openai import ChatOpenAI
model = ModelConfig(
name="gpt-6-custom",
display_name="GPT-6 custom",
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-6.1-sol",
use_responses_api=False, # operator deliberately opted out
output_version=None,
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-custom")
assert captured.get("use_responses_api") is False
assert captured.get("output_version") is None
def test_non_gpt6_openai_model_not_rerouted(monkeypatch):
"""The default must not fire for non-gpt-6 OpenAI models (gpt-4o, gpt-5, …),
which keep their historical chat-completions routing.
"""
from langchain_openai import ChatOpenAI
model = _make_model("gpt-4o", use="langchain_openai:ChatOpenAI")
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-4o")
assert captured.get("use_responses_api") is None
assert captured.get("output_version") is None
def test_gpt6_custom_base_url_not_rerouted(monkeypatch):
"""A gpt-6 model pointed at an OpenAI-compatible gateway/proxy via ``base_url``
must not be silently re-pointed at ``/responses`` — many gateways (LiteLLM,
vLLM, one-api, …) do not implement it, so the operator owns routing there.
"""
from langchain_openai import ChatOpenAI
model = ModelConfig(
name="gpt-6-gateway",
display_name="GPT-6 via gateway",
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-6.1-sol",
base_url="https://my-gateway.example.com/v1",
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-gateway")
assert captured.get("use_responses_api") is None
assert captured.get("output_version") is None
def test_gpt6_openai_api_base_alias_not_rerouted(monkeypatch):
"""The legacy ``openai_api_base`` alias is treated like a custom endpoint:
after ``_normalize_openai_base_url`` it is the canonical key the gate reads.
"""
from langchain_openai import ChatOpenAI
model = ModelConfig(
name="gpt-6-alias",
display_name="GPT-6 alias endpoint",
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-6.1-sol",
openai_api_base="https://my-gateway.example.com/v1",
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-alias")
assert captured.get("use_responses_api") is None
assert captured.get("output_version") is None
def test_gpt6_azure_not_rerouted(monkeypatch):
"""``AzureChatOpenAI`` is a ``BaseChatOpenAI`` subclass, but Azure's Responses
API additionally requires ``api-version >= 2025-04-01-preview``; an Azure
gpt-6 deployment must not be silently flipped.
"""
from langchain_openai import AzureChatOpenAI
model = ModelConfig(
name="gpt-6-azure",
display_name="GPT-6 Azure",
description=None,
use="langchain_openai:AzureChatOpenAI",
model="gpt-6.1-sol",
azure_endpoint="https://example.openai.azure.com/",
azure_deployment="gpt-6-deploy",
api_version="2024-02-01",
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(AzureChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-azure")
assert captured.get("use_responses_api") is None
assert captured.get("output_version") is None
def test_gpt6_output_version_only_still_routes(monkeypatch):
"""An operator who pinned only ``output_version`` (implying Responses
formatting) without ``use_responses_api`` must still get Responses routing —
otherwise gpt-6 stays on /v1/chat/completions and the issue #6841 400s
silently reappear. The explicit ``output_version`` is preserved.
"""
from langchain_openai import ChatOpenAI
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
model = ModelConfig(
name="gpt-6-output-version",
display_name="GPT-6 pinned output version",
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-6.1-sol",
output_version="responses/v1",
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-output-version")
assert captured.get("use_responses_api") is True
assert captured.get("output_version") == "responses/v1"
@pytest.mark.parametrize("endpoint_env", ["OPENAI_BASE_URL", "OPENAI_API_BASE"])
def test_gpt6_env_var_base_url_not_rerouted(monkeypatch, endpoint_env):
"""A gpt-6 model with no ``base_url`` in settings but a gateway wired purely
via either supported endpoint env var must not be re-pointed at ``/responses``.
Clear both variables first so each case exercises its own gate.
"""
from langchain_openai import ChatOpenAI
model = _make_model("gpt-6.1-sol", use="langchain_openai:ChatOpenAI")
cfg = _make_app_config([model])
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
monkeypatch.setenv(endpoint_env, "https://env-gateway.example.com/v1")
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6.1-sol")
assert captured.get("use_responses_api") is None
assert captured.get("output_version") is None
def test_gpt6_explicit_responses_config_wins_on_custom_endpoint(monkeypatch):
"""An explicit ``use_responses_api`` is the operator's routing decision and
must win even on a custom endpoint: the explicit-key check precedes the
endpoint gate, so a ``base_url``-configured gpt-6 model keeps its explicit
value instead of being silently left on default chat-completions routing.
"""
from langchain_openai import ChatOpenAI
model = ModelConfig(
name="gpt-6-gateway-explicit",
display_name="GPT-6 gateway with explicit routing",
description=None,
use="langchain_openai:ChatOpenAI",
model="gpt-6.1-sol",
base_url="https://my-gateway.example.com/v1",
use_responses_api=True,
output_version="responses/v1",
)
cfg = _make_app_config([model])
captured: dict = {}
_patch_factory(monkeypatch, cfg, model_class=_capturing_class(ChatOpenAI, captured))
factory_module.create_chat_model(name="gpt-6-gateway-explicit")
assert captured.get("use_responses_api") is True
assert captured.get("output_version") == "responses/v1"