This commit is contained in:
2026-09-04 11:16:23 +08:00
parent 9e27f11bb7
commit 921ba5eab3
36 changed files with 2665 additions and 29 deletions
@@ -46,13 +46,67 @@ class StructuredModelGateway:
async def call_tool(self, *, messages: list[dict[str, Any]], tool: dict[str, Any], provider_id: str, model_id: str, required_tool_name: str) -> dict[str, Any]:
provider, model = self._provider_model(provider_id, model_id)
payload = self._payload(provider, model.id, messages, [tool], required_tool_name)
response = await self._request(provider, payload)
response, compatibility_mode = await self._request_tool_call(
provider, model.id, messages, [tool], required_tool_name,
)
try:
return self._normalized_response(provider, response)
normalized = self._normalized_response(provider, response)
normalized["usage"]["structured_compatibility_mode"] = compatibility_mode
return normalized
except (KeyError, TypeError, ValueError) as error:
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
async def _request_tool_call(
self,
provider: ProviderConfig,
model_id: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
required_tool_name: str,
) -> tuple[dict[str, Any], str]:
"""Keep forced tool choice unless a provider explicitly rejects thinking mode.
Some OpenAI-compatible gateways reject an otherwise valid forced
``tool_choice`` whenever reasoning is enabled or implicit. This is a
request compatibility issue rather than an author error. First retry
without the client-requested reasoning option; only if that exact
rejection remains do we allow automatic choice among the single tool
already exposed to the model. The workflow's one-call/schema checks
still reject free-form or wrong-tool responses.
"""
try:
return await self._request(
provider,
self._payload(provider, model_id, messages, tools, required_tool_name),
), "forced"
except StructuredModelError as first_error:
if not self._thinking_tool_choice_rejection(first_error):
raise
try:
return await self._request(
provider,
self._payload(
provider, model_id, messages, tools, required_tool_name,
include_reasoning=False,
),
), "reasoning_disabled"
except StructuredModelError as second_error:
if not self._thinking_tool_choice_rejection(second_error):
raise
return await self._request(
provider,
self._payload(
provider, model_id, messages, tools, required_tool_name,
include_reasoning=False,
force_tool_name=False,
),
), "single_tool_auto"
@staticmethod
def _thinking_tool_choice_rejection(error: StructuredModelError) -> bool:
message = str(error).casefold()
return "thinking" in message and ("tool_choice" in message or "tool choice" in message)
async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]:
failures: list[dict[str, str]] = []
usage = {"prompt_tokens": 0, "completion_tokens": 0}
@@ -190,20 +244,31 @@ class StructuredModelGateway:
})
return values
def _payload(self, provider: ProviderConfig, model_id: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]], required_tool_name: str) -> dict[str, Any]:
def _payload(
self,
provider: ProviderConfig,
model_id: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
required_tool_name: str,
*,
include_reasoning: bool = True,
force_tool_name: bool = True,
) -> dict[str, Any]:
if len(tools) != 1 or not required_tool_name:
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
request_options = provider.request_options if include_reasoning else {}
if provider.api_style == "responses":
payload: dict[str, Any] = {
"model": model_id, "input": self._responses_input(messages), "tools": self._responses_tools(tools),
"tool_choice": {"type": "function", "name": required_tool_name},
**provider.request_options,
"tool_choice": {"type": "function", "name": required_tool_name} if force_tool_name else "auto",
**request_options,
}
return payload
return {
"model": model_id, "messages": messages, "tools": tools,
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
"temperature": 0, **provider.request_options,
"tool_choice": {"type": "function", "function": {"name": required_tool_name}} if force_tool_name else "auto",
"temperature": 0, **request_options,
}
@staticmethod