From 11979fdb573ea87abf8e1d4c0c85665ac9f8d90c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=87=E5=AE=B6=E8=89=AF?= <102887115+wjl8636@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:53:19 +0800 Subject: [PATCH] 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 * 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 /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 Co-authored-by: Willem Jiang --- .../harness/deerflow/models/AGENTS.md | 1 + .../harness/deerflow/models/factory.py | 96 ++++++++ backend/tests/test_model_factory.py | 226 ++++++++++++++++++ 3 files changed, 323 insertions(+) diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 59e1826599..cf57efcf39 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -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`) diff --git a/backend/packages/harness/deerflow/models/factory.py b/backend/packages/harness/deerflow/models/factory.py index abf89071da..cd502b2270 100644 --- a/backend/packages/harness/deerflow/models/factory.py +++ b/backend/packages/harness/deerflow/models/factory.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. 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 /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: diff --git a/backend/tests/test_model_factory.py b/backend/tests/test_model_factory.py index 0a073699ea..51a97caee0 100644 --- a/backend/tests/test_model_factory.py +++ b/backend/tests/test_model_factory.py @@ -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"