267 lines
15 KiB
Python
267 lines
15 KiB
Python
"""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}")
|