重构 改为标准化输出
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||
|
||||
Raw tool arguments are intentionally preserved. Callers must run their
|
||||
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from app.settings import ProviderConfig, ProviderModel, Settings
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
|
||||
class StructuredModelError(AdapterUnavailable):
|
||||
"""A provider-side structured-output failure, never an author mistake.
|
||||
|
||||
Invalid provider responses and provider-side schema rejections are outside
|
||||
the model's tool arguments. The workflow must park the task for bounded
|
||||
service recovery instead of spending the author-format retry budget.
|
||||
"""
|
||||
|
||||
|
||||
class StructuredTransportError(StructuredModelError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StructuredResponse:
|
||||
tool_calls: list[dict[str, Any]]
|
||||
usage: dict[str, int]
|
||||
raw_response: dict[str, Any]
|
||||
|
||||
|
||||
class StructuredModelGateway:
|
||||
"""Bounded HTTP adapter; it never performs tool execution or re-asks."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
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)
|
||||
try:
|
||||
return self._normalized_response(provider, response)
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise StructuredModelError("Provider response cannot be normalized as a structured tool-call result") from error
|
||||
|
||||
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}
|
||||
for tool in tools:
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
failures.append({"tool": "", "message": "Tool has no function name"})
|
||||
continue
|
||||
try:
|
||||
response = await self.call_tool(
|
||||
messages=self._conformance_messages(tool),
|
||||
tool=tool, provider_id=provider_id, model_id=model_id, required_tool_name=name,
|
||||
)
|
||||
usage["prompt_tokens"] += int(response["usage"]["prompt_tokens"])
|
||||
usage["completion_tokens"] += int(response["usage"]["completion_tokens"])
|
||||
calls = response["tool_calls"]
|
||||
if len(calls) != 1:
|
||||
failures.append({"tool": name, "message": "Provider did not return exactly one tool call"})
|
||||
continue
|
||||
function = calls[0].get("function") if isinstance(calls[0], dict) and isinstance(calls[0].get("function"), dict) else {}
|
||||
if str(function.get("name") or "") != name:
|
||||
failures.append({"tool": name, "message": "Provider returned a different tool"})
|
||||
continue
|
||||
raw = str(function.get("arguments") or "")
|
||||
if len(raw.encode("utf-8")) > 48_000:
|
||||
failures.append({"tool": name, "message": "Provider arguments exceed the protocol byte limit"})
|
||||
continue
|
||||
try:
|
||||
arguments = json.loads(raw)
|
||||
except json.JSONDecodeError as error:
|
||||
failures.append({"tool": name, "message": f"Provider arguments are not JSON: {error.msg}"})
|
||||
continue
|
||||
if not isinstance(arguments, dict):
|
||||
failures.append({"tool": name, "message": "Provider arguments are not a JSON object"})
|
||||
continue
|
||||
schema = (tool.get("function") or {}).get("parameters")
|
||||
errors = list(Draft202012Validator(schema).iter_errors(arguments)) if isinstance(schema, dict) else []
|
||||
if errors:
|
||||
failures.append({"tool": name, "message": f"Provider arguments violate schema: {errors[0].message[:300]}"})
|
||||
except StructuredTransportError as error:
|
||||
# This probe cannot establish unsupported capability while
|
||||
# the provider is unavailable. Stop instead of charging for
|
||||
# every remaining schema after the first transport failure.
|
||||
failures.append({"tool": name, "message": str(error)[:500]})
|
||||
return {
|
||||
"supported": False,
|
||||
"failures": failures,
|
||||
"usage": usage,
|
||||
"mode": "openai_compatible_tool_calling",
|
||||
"probe_unavailable": True,
|
||||
}
|
||||
except Exception as error:
|
||||
failures.append({"tool": name, "message": str(error)[:500]})
|
||||
return {
|
||||
"supported": not failures,
|
||||
"failures": failures,
|
||||
"usage": usage,
|
||||
"mode": "openai_compatible_tool_calling",
|
||||
"probe_unavailable": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Give capability probes the same selector fact production exposes.
|
||||
|
||||
A required opaque selector is not inferable from a generic request for
|
||||
a minimal example. Production fragment turns expose a topology token
|
||||
before the fragment tool, so the probe must do the same while still
|
||||
letting the provider generate every other schema field itself.
|
||||
"""
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
name = str(function.get("name") or "")
|
||||
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||
if selector_required:
|
||||
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||
instruction += (
|
||||
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||
)
|
||||
if name == "review_candidate":
|
||||
instruction += (
|
||||
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||
)
|
||||
elif name == "review_final":
|
||||
instruction += (
|
||||
" This is a final-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||
)
|
||||
return [{"role": "system", "content": instruction}]
|
||||
|
||||
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||
return self.settings.resolve_model(provider_id, model_id)
|
||||
|
||||
@staticmethod
|
||||
def _responses_input(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
values: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
role = "developer" if message.get("role") == "system" else str(message.get("role") or "user")
|
||||
raw_content = message.get("content")
|
||||
if isinstance(raw_content, str):
|
||||
content: list[dict[str, Any]] = [{"type": "input_text", "text": raw_content}]
|
||||
elif isinstance(raw_content, list):
|
||||
content = []
|
||||
for item in raw_content:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("type") == "text" and isinstance(item.get("text"), str):
|
||||
content.append({"type": "input_text", "text": item["text"]})
|
||||
elif item.get("type") == "image_url" and isinstance(item.get("image_url"), dict) and isinstance(item["image_url"].get("url"), str):
|
||||
content.append({"type": "input_image", "image_url": item["image_url"]["url"]})
|
||||
if not content:
|
||||
content = [{"type": "input_text", "text": ""}]
|
||||
else:
|
||||
content = [{"type": "input_text", "text": ""}]
|
||||
values.append({"role": role, "content": content})
|
||||
return values
|
||||
|
||||
@staticmethod
|
||||
def _responses_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
values: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
values.append({
|
||||
"type": "function", "name": str(function.get("name") or ""),
|
||||
"description": str(function.get("description") or ""),
|
||||
"parameters": function.get("parameters") if isinstance(function.get("parameters"), dict) else {},
|
||||
})
|
||||
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]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
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,
|
||||
}
|
||||
return payload
|
||||
return {
|
||||
"model": model_id, "messages": messages, "tools": tools,
|
||||
"tool_choice": {"type": "function", "function": {"name": required_tool_name}},
|
||||
"temperature": 0, **provider.request_options,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalized_response(provider: ProviderConfig, response: dict[str, Any]) -> dict[str, Any]:
|
||||
usage = response.get("usage") if isinstance(response.get("usage"), dict) else {}
|
||||
if provider.api_style == "responses":
|
||||
output = response.get("output") if isinstance(response.get("output"), list) else []
|
||||
calls = [
|
||||
{"function": {"name": str(item.get("name") or ""), "arguments": str(item.get("arguments") or "")}}
|
||||
for item in output
|
||||
if isinstance(item, dict) and item.get("type") == "function_call"
|
||||
]
|
||||
prompt_tokens = int(usage.get("input_tokens") or usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("output_tokens") or usage.get("completion_tokens") or 0)
|
||||
else:
|
||||
choices = response.get("choices") if isinstance(response.get("choices"), list) else []
|
||||
message = choices[0].get("message") if choices and isinstance(choices[0], dict) and isinstance(choices[0].get("message"), dict) else {}
|
||||
calls = message.get("tool_calls") if isinstance(message, dict) and isinstance(message.get("tool_calls"), list) else []
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
return {
|
||||
"tool_calls": calls,
|
||||
"usage": {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": int(usage.get("total_tokens") or prompt_tokens + completion_tokens),
|
||||
"usage_available": bool(usage),
|
||||
},
|
||||
"raw_response": response,
|
||||
}
|
||||
|
||||
async def _request(self, provider: ProviderConfig, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"}
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_s) as client:
|
||||
endpoint = "responses" if provider.api_style == "responses" else "chat/completions"
|
||||
response = await asyncio.wait_for(client.post(f"{provider.base_url}/{endpoint}", headers=headers, json=payload), timeout=self.settings.llm_timeout_s)
|
||||
if response.status_code >= 500 or response.status_code in {408, 429}:
|
||||
raise StructuredTransportError(f"Provider temporary failure ({response.status_code}): {response.text[:300]}")
|
||||
if response.status_code >= 400:
|
||||
raise StructuredModelError(f"Provider rejected structured request ({response.status_code}): {response.text[:500]}")
|
||||
try:
|
||||
value = response.json()
|
||||
except ValueError as error:
|
||||
raise StructuredModelError("Provider response is not valid JSON") from error
|
||||
if not isinstance(value, dict):
|
||||
raise StructuredModelError("Provider response is not a JSON object")
|
||||
if provider.api_style == "responses":
|
||||
if not isinstance(value.get("output"), list):
|
||||
raise StructuredModelError("Provider response has no Responses API output array")
|
||||
elif not isinstance(value.get("choices"), list) or not value["choices"]:
|
||||
raise StructuredModelError("Provider response has no completion choices")
|
||||
return value
|
||||
except (httpx.HTTPError, asyncio.TimeoutError, StructuredTransportError) as error:
|
||||
last_error = error
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(0.5 * (2**attempt))
|
||||
raise StructuredTransportError(f"Provider transport unavailable after 3 attempts: {last_error}")
|
||||
Reference in New Issue
Block a user