Files
cdsl-cad/backend/app/cad_agent/adapters/structured_llm.py
T
2026-09-01 14:10:23 +08:00

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}")