"""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) response, compatibility_mode = await self._request_tool_call( provider, model.id, messages, [tool], required_tool_name, ) try: 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} 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, *, 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} 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}} if force_tool_name else "auto", "temperature": 0, **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}")