From 82c77ff9b98a7efea77460cc481cb53d78e9ad4d Mon Sep 17 00:00:00 2001 From: lyswty <68141859@qq.com> Date: Thu, 10 Sep 2026 15:29:30 +0800 Subject: [PATCH] feat(web): add openai-native backend for Codex server-side web_search Declares OpenAI's provider-executed Responses `web_search` built-in in place of the client-side `web_search` function, mirroring the existing xAI native-search path. Selected via `web.search_backend: openai-native`; search-only, so `web_extract` keeps resolving to its own backend. The Responses adapter already recognises built-in tool types (`_RESPONSES_BUILTIN_TOOL_TYPES`) and preflight passes them through, so the only missing piece was the swap itself plus a provider name for the config to point at. Gating is deliberate: two-sided (Codex backend AND a selected openai-native backend) and fail-closed, so a custom OpenAI-compatible endpoint or an unresolved provider leaves the client tool untouched. --- agent/transports/codex.py | 42 ++++++++++++- plugins/web/openai_native/__init__.py | 9 +++ plugins/web/openai_native/plugin.yaml | 7 +++ plugins/web/openai_native/provider.py | 88 +++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 plugins/web/openai_native/__init__.py create mode 100644 plugins/web/openai_native/plugin.yaml create mode 100644 plugins/web/openai_native/provider.py diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 985c498d09..ad2d3bc86a 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -171,11 +171,40 @@ def _xai_prefers_native_web_search() -> bool: return True -def _alias_wire_tools(response_tools: Any, params: dict[str, Any], is_xai_responses: bool) -> tuple[Any, dict[str, str]]: +def _openai_prefers_native_web_search() -> bool: + """True when the active web-search backend selects OpenAI's server-side ``web_search``. + + Same contract as :func:`_xai_prefers_native_web_search` with one deliberate + difference: it fails CLOSED (False). A resolution failure must leave the client-side + Hermes tool in place rather than swap in a built-in the endpoint might reject. + + Only consulted for the Codex backend (``chatgpt.com/backend-api/codex``); a custom + OpenAI-compatible endpoint does not implement the server-side tool. + """ + try: + from agent.web_search_registry import get_active_search_provider + + provider = get_active_search_provider() + if provider is not None: + return getattr(provider, "name", None) == "openai-native" + + from tools.web_tools import _get_search_backend + + return (_get_search_backend() or "").strip().lower() == "openai-native" + except Exception: # noqa: BLE001 — a probe failure must not change the request shape + return False + + +def _alias_wire_tools( + response_tools: Any, params: dict[str, Any], is_xai_responses: bool, is_codex_backend: bool = False, +) -> tuple[Any, dict[str, str]]: """Apply provider-reserved tool-name aliasing; returns ``(tools, {alias: original})`` for THIS request. xAI: a client ``web_search`` collides with Grok's native search — native mode swaps it 1:1 for the built-in, client mode keeps Hermes dispatch under an alias. + + OpenAI Codex: the Responses endpoint carries the same collision, so the backend + selection drives the same 1:1 swap (``web.search_backend: openai-native``). """ wire_aliases: dict[str, str] = {} @@ -190,6 +219,13 @@ def _alias_wire_tools(response_tools: Any, params: dict[str, Any], is_xai_respon {**t, "name": _XAI_CLIENT_WEB_SEARCH_ALIAS} if is_client_web_search(t) else t for t in response_tools ] wire_aliases[_XAI_CLIENT_WEB_SEARCH_ALIAS] = "web_search" + # OpenAI Codex: the Responses endpoint exposes the same server-executed ``web_search``, + # and a client-side function of that name collides with it the same way. Unlike xAI there + # is no alias fallback: when the user has not selected ``openai-native`` we leave the + # client tool untouched, so an endpoint that cannot host the built-in never breaks. + if is_codex_backend and response_tools and any(is_client_web_search(t) for t in response_tools): + if _openai_prefers_native_web_search(): + response_tools = [t for t in response_tools if not is_client_web_search(t)] + [{"type": "web_search"}] # OpenCode Responses backends reserve web_search / search_files as function names (HTTP 400 "custom # function name 'X' is reserved", #85589). Alias them on the wire; normalize_response maps them back. if response_tools and _is_opencode_responses_backend(params): @@ -612,7 +648,9 @@ class ResponsesApiTransport(ProviderTransport): native_compaction_active = _native_compaction_active(context_management) reasoning_effort, reasoning_enabled = _resolve_reasoning(model, params) - response_tools, self._last_wire_aliases = _alias_wire_tools(self.convert_tools(tools), params, is_xai_responses) + response_tools, self._last_wire_aliases = _alias_wire_tools( + self.convert_tools(tools), params, is_xai_responses, is_codex_backend, + ) # Lazy: provider plugins import this transport during model_metadata init. from agent.model_metadata import strip_codex_context_variant_suffix as _strip_ctx_variant diff --git a/plugins/web/openai_native/__init__.py b/plugins/web/openai_native/__init__.py new file mode 100644 index 0000000000..ad16e6b92f --- /dev/null +++ b/plugins/web/openai_native/__init__.py @@ -0,0 +1,9 @@ +"""OpenAI native web search plugin — bundled, auto-loaded.""" + +from __future__ import annotations + +from plugins.web.openai_native.provider import OpenAINativeWebSearchProvider + + +def register(ctx) -> None: + ctx.register_web_search_provider(OpenAINativeWebSearchProvider()) diff --git a/plugins/web/openai_native/plugin.yaml b/plugins/web/openai_native/plugin.yaml new file mode 100644 index 0000000000..cc621c0751 --- /dev/null +++ b/plugins/web/openai_native/plugin.yaml @@ -0,0 +1,7 @@ +name: web-openai-native +version: 1.0.0 +description: "OpenAI native web search — declares the Responses API server-side ``web_search`` built-in instead of running a client-side search. Requires the Codex Responses transport plus openai-codex OAuth (``hermes auth --provider openai-codex``)." +author: NousResearch +kind: backend +provides_web_providers: + - openai-native diff --git a/plugins/web/openai_native/provider.py b/plugins/web/openai_native/provider.py new file mode 100644 index 0000000000..4bbb7331e3 --- /dev/null +++ b/plugins/web/openai_native/provider.py @@ -0,0 +1,88 @@ +"""OpenAI native web search — declares the Responses API server-side ``web_search`` built-in. + +Config: ``web.search_backend: openai-native`` (or ``web.backend``). +Auth: openai-codex OAuth (``hermes auth --provider openai-codex``); no API key of its own. + +Unlike every other provider here, this one never executes a search itself. Selecting it +tells the Codex Responses transport to declare the provider-executed ``web_search`` tool +(``{"type": "web_search"}``) in place of the client-side ``web_search`` function, so the +model drives search server-side. The transport performs that swap; this class exists so +``web.search_backend`` has a real provider name to point at. + +Auth gating lives here rather than in the transport because the transport must stay +reachable for a user who configured this backend but has not signed in yet — they get the +clear "sign in" error from :meth:`search` rather than a silently different backend. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +from plugins.web._common import BaseWebSearchProvider, search_fail + +_UNSUPPORTED_MSG = ( + "openai-native declares OpenAI's server-side web_search tool; it cannot run as a " + "client-side search and requires the Codex Responses transport (provider " + "openai-codex). For client-side search use firecrawl (default) or another backend." +) + + +def _dget(obj: Any, key: str) -> Any: + return obj.get(key) if isinstance(obj, dict) else None + + +def has_codex_credentials() -> bool: + """Cheap probe: True when openai-codex OAuth tokens are *likely* usable. + + Mirrors ``tools/xai_http.has_xai_credentials`` — deliberately avoids + ``resolve_codex_runtime_credentials`` (disk locks, OAuth network refresh), because + this runs on every ``hermes tools`` repaint. Checks, fast-to-slow: + ``providers.openai-codex.tokens.access_token`` in ``auth.json``, then any + ``credential_pool.openai-codex`` entry carrying an ``access_token`` (pool-only + multi-account grants never write the providers singleton). Returns False on any + exception so a corrupted auth store cannot block other availability scans. + """ + try: + from hermes_constants import get_hermes_home + + auth_path = get_hermes_home() / "auth.json" + if not auth_path.exists(): + return False + store = json.loads(auth_path.read_text(encoding="utf-8-sig")) + tokens = _dget(_dget(_dget(store, "providers"), "openai-codex"), "tokens") + if str(_dget(tokens, "access_token") or "").strip(): + return True + entries = _dget(_dget(store, "credential_pool"), "openai-codex") + return isinstance(entries, list) and any( + isinstance(e, dict) and str(e.get("access_token", "") or "").strip() for e in entries + ) + except Exception: # noqa: BLE001 — availability must never raise + return False + + +class OpenAINativeWebSearchProvider(BaseWebSearchProvider): + """Marker provider: the Codex Responses transport swaps the client ``web_search`` + function for the server-executed built-in when this backend is active.""" + + NAME = "openai-native" + DISPLAY_NAME = "OpenAI Native Web Search (Codex Responses)" + + def is_available(self) -> bool: + return has_codex_credentials() + + def search(self, query: str, limit: int = 5) -> Dict[str, Any]: + """Never called on a successful native turn — the transport replaces the tool + before the request goes out. Reached only when the active transport cannot host + the built-in, so fail loudly instead of returning an empty result set.""" + return search_fail(_UNSUPPORTED_MSG) + + def get_setup_schema(self) -> Dict[str, Any]: + from plugins.web._common import setup_schema + + return setup_schema( + self.DISPLAY_NAME, + "native", + "由模型服务端执行搜索(需 Codex Responses transport + openai-codex 登录);仅搜索,提取仍用其他后端", + "", + )