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:
@@ -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
|
||||
|
||||
9
plugins/web/openai_native/__init__.py
Normal file
9
plugins/web/openai_native/__init__.py
Normal 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())
|
||||
7
plugins/web/openai_native/plugin.yaml
Normal file
7
plugins/web/openai_native/plugin.yaml
Normal 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
|
||||
88
plugins/web/openai_native/provider.py
Normal file
88
plugins/web/openai_native/provider.py
Normal 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 登录);仅搜索,提取仍用其他后端",
|
||||
"",
|
||||
)
|
||||
Reference in New Issue
Block a user