feat: add provider-native request switches (#5254)

This commit is contained in:
chengyongru
2026-08-05 18:26:39 +08:00
committed by GitHub
parent 5a1ab44baa
commit 67805f5db8
32 changed files with 1130 additions and 52 deletions
+79 -5
View File
@@ -56,6 +56,32 @@ if TYPE_CHECKING:
# that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None
def _is_hosted_web_search_type(value: object) -> bool:
return isinstance(value, str) and (
value == "web_search" or value.startswith("web_search_")
)
def _is_hosted_web_search_tool(tool: object) -> bool:
if not isinstance(tool, dict):
return False
tool_type = cast(dict[object, object], tool).get("type")
return _is_hosted_web_search_type(tool_type)
def _is_named_function_tool(tool: object, name: str) -> bool:
"""Return whether a Responses tool is a function with the given name."""
if not isinstance(tool, dict):
return False
record = cast(dict[object, object], tool)
if record.get("type") != "function":
return False
function = record.get("function")
if isinstance(function, dict):
return cast(dict[object, object], function).get("name") == name
return record.get("name") == name
_ALLOWED_MSG_KEYS = frozenset({
"role", "content", "tool_calls", "tool_call_id", "name",
"reasoning_content", "extra_content",
@@ -469,7 +495,7 @@ class OpenAICompatProvider(LLMProvider):
self.default_model = default_model
self.extra_headers = extra_headers or {}
self._spec = spec
self._extra_body = extra_body or {}
self._extra_body = dict(extra_body or {})
self._api_type = api_type if spec and spec.name == "openai" else "auto"
self._extra_query = extra_query or {}
self._proxy = proxy or None
@@ -974,8 +1000,8 @@ class OpenAICompatProvider(LLMProvider):
provider_responses = spec_name in ("openai", "github_copilot")
if not provider_responses and not model_responses:
return False
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
if self._responses_is_required():
# Explicit Responses-only request fields are mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
@@ -994,6 +1020,25 @@ class OpenAICompatProvider(LLMProvider):
return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_is_required(self) -> bool:
return self._api_type == "responses" or self._hosted_web_search_enabled()
def _hosted_web_search_enabled(self) -> bool:
extra_body = getattr(self, "_extra_body", {})
configured_tools = extra_body.get("tools")
if "tools" in extra_body:
return isinstance(configured_tools, list) and any(
_is_hosted_web_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
return bool(
self._spec
and any(
_is_hosted_web_search_type(tool_type)
for tool_type in getattr(self._spec, "responses_default_tools", ())
)
)
def _responses_state_provider(self) -> str:
spec_name = self._spec.name if self._spec is not None else "custom"
effective_base = self._effective_base or "https://api.openai.com/v1"
@@ -1157,9 +1202,38 @@ class OpenAICompatProvider(LLMProvider):
body["tool_choice"] = tool_choice or "auto"
extra_body = getattr(self, "_extra_body", {})
default_tools = getattr(self._spec, "responses_default_tools", ())
if "tools" not in extra_body and default_tools:
body["tools"] = [
*cast(list[object], body.get("tools", [])),
*({"type": tool_type} for tool_type in default_tools),
]
if extra_body:
body = _merge_responses_extra_body(body, extra_body)
if self._hosted_web_search_enabled():
configured_tools = body.get("tools")
if isinstance(configured_tools, list):
managed_tools: list[object] = []
hosted_search_seen = False
for tool in cast(list[object], configured_tools):
if _is_named_function_tool(tool, "web_search"):
continue
if _is_hosted_web_search_tool(tool):
if hosted_search_seen:
continue
hosted_search_seen = True
managed_tools.append(tool)
body["tools"] = managed_tools
if self._spec and self._spec.name == "openai":
source_include = "web_search_call.action.sources"
configured_include = body.get("include")
if isinstance(configured_include, list):
if source_include not in configured_include:
body["include"] = [*configured_include, source_include]
else:
body["include"] = [source_include]
return body
async def _create_response_with_compaction_fallback(
@@ -1771,7 +1845,7 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would
# hide the real error.
raise
if self._api_type == "responses":
if self._responses_is_required():
raise
if not self._should_fallback_from_responses_error(responses_error):
raise
@@ -1867,7 +1941,7 @@ class OpenAICompatProvider(LLMProvider):
# falling back to /chat/completions cannot succeed and would
# hide the real error.
raise
if self._api_type == "responses":
if self._responses_is_required():
raise
if not self._should_fallback_from_responses_error(responses_error):
raise
+79 -2
View File
@@ -89,6 +89,77 @@ def _response_object_list(value: object) -> list[dict[str, Any]]:
]
def _hosted_web_search_event(
event: object,
event_type: object,
) -> dict[str, Any] | None:
"""Map the official web-search output item pair onto normal tool progress."""
if event_type not in {"response.output_item.added", "response.output_item.done"}:
return None
event_object = _response_object(event) or {}
item = _response_object(event_object.get("item")) or {}
if item.get("type") != "web_search_call":
return None
call_id = item.get("id") or item.get("call_id") or event_object.get("item_id")
if not isinstance(call_id, str) or not call_id:
return None
action = _response_object(item.get("action")) or {}
raw_queries = action.get("queries")
queries = (
[
query.strip()
for query in cast(list[object], raw_queries)
if isinstance(query, str) and query.strip()
][:4]
if isinstance(raw_queries, list)
else []
)
query = " · ".join(queries)
if not query:
query = next(
(
value.strip()
for key in ("query", "pattern", "url")
if isinstance((value := action.get(key)), str) and value.strip()
),
"",
)
arguments = {"query": query[:1000]} if query else {}
phase = "start" if event_type == "response.output_item.added" else "end"
result: dict[str, Any] | None = None
if phase == "end":
status = item.get("status")
result = {"status": status if isinstance(status, str) else "completed"}
raw_sources = action.get("sources")
if isinstance(raw_sources, list):
sources: list[dict[str, str]] = []
for raw_source in cast(list[object], raw_sources):
source = _response_object(raw_source) or {}
url = source.get("url")
if not isinstance(url, str) or not url.strip():
continue
visible_source = {"url": url.strip()[:2048]}
title = source.get("title")
if isinstance(title, str) and title.strip():
visible_source["title"] = title.strip()[:300]
sources.append(visible_source)
if len(sources) == 8:
break
if sources:
result["sources"] = sources
return {
"kind": "hosted_tool",
"phase": phase,
"call_id": call_id,
"name": "web_search",
"arguments": arguments,
"result": result,
}
def map_finish_reason(status: str | None) -> str:
"""Map a Responses API status string to a Chat-Completions-style finish_reason."""
return FINISH_REASON_MAP.get(status or "completed", "stop")
@@ -269,11 +340,14 @@ async def consume_sse_with_reasoning(
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
async for event in iter_sse(response):
if on_response_event:
await on_response_event(event)
event_type = event.get("type")
if on_tool_call_delta and (
hosted_event := _hosted_web_search_event(event, event_type)
):
await on_tool_call_delta(hosted_event)
if event_type == "response.output_item.added":
item = _as_json_object(event.get("item")) or {}
if item.get("type") == "function_call":
@@ -555,10 +629,13 @@ async def consume_sdk_stream(
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
async for raw_event in stream:
event: Any = raw_event
event_type = getattr(event, "type", None)
if on_tool_call_delta and (
hosted_event := _hosted_web_search_event(event, event_type)
):
await on_tool_call_delta(hosted_event)
if event_type == "response.output_item.added":
item = getattr(event, "item", None)
if item and getattr(item, "type", None) == "function_call":
+5
View File
@@ -116,6 +116,10 @@ class ProviderSpec:
# Flash is supported before V4 Pro).
responses_models: tuple[str, ...] = ()
# Provider-hosted Responses tools sent unless extraBody.tools explicitly
# supplies the hosted-tool selection. Values are raw Responses tool types.
responses_default_tools: tuple[str, ...] = ()
# When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -479,6 +483,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
responses_default_tools=("web_search",),
),
# Gemini: Google's OpenAI-compatible endpoint
ProviderSpec(
+38 -5
View File
@@ -46,6 +46,19 @@ _SENSITIVE_ERROR_KEYS = {
}
def _is_hosted_x_search_tool(value: object) -> bool:
if not isinstance(value, dict):
return False
return cast(dict[object, object], value).get("type") == "x_search"
def _is_named_x_search_tool(value: object) -> bool:
if not isinstance(value, dict):
return False
record = cast(dict[object, object], value)
return record.get("type") == "function" and record.get("name") == "x_search"
class XAIGrokProvider(LLMProvider):
"""Call xAI's subscription proxy and expose supported hosted tools."""
@@ -112,13 +125,27 @@ class XAIGrokProvider(LLMProvider):
stage = "oauth_token"
try:
token = await asyncio.to_thread(get_xai_oauth_token, proxy=self.proxy)
stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model)
configured_tools = self._extra_body.get("tools")
tools_are_explicit = "tools" in self._extra_body
configured_hosted_search = (
isinstance(configured_tools, list)
and any(
_is_hosted_x_search_tool(tool)
for tool in cast(list[object], configured_tools)
)
)
supports_backend_search = False
if not tools_are_explicit:
stage = "model_capabilities"
supports_backend_search = await self._supports_backend_search(token, wire_model)
converted_tools = convert_tools(tools or [])
if supports_backend_search:
if isinstance(configured_tools, list):
converted_tools.extend(cast(list[dict[str, Any]], configured_tools))
if supports_backend_search or configured_hosted_search:
converted_tools = [
tool for tool in converted_tools if tool.get("name") != "x_search"
tool for tool in converted_tools if not _is_named_x_search_tool(tool)
]
if supports_backend_search:
converted_tools.append({"type": "x_search"})
body: dict[str, Any] = {
@@ -137,7 +164,13 @@ class XAIGrokProvider(LLMProvider):
"reasoning": _build_reasoning_options(reasoning_effort),
}
if self._extra_body:
body.update(self._extra_body)
body.update({
key: value
for key, value in self._extra_body.items()
if key != "tools"
})
if tools_are_explicit and not isinstance(configured_tools, list):
body["tools"] = configured_tools
headers = _build_headers(token.access, wire_model)
stage = "xai_request"