mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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:
parent
e02d5e2fc3
commit
11979fdb57
@ -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`)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user