feat: add provider-native request switches (#5254)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user