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.
This commit is contained in:
lyswty
2026-09-10 15:29:30 +08:00
committed by teknium1
parent 633dda6d7f
commit 82c77ff9b9
4 changed files with 144 additions and 2 deletions

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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 登录);仅搜索,提取仍用其他后端",
"",
)