feat: LLM-native structured output via JSON schema enforcement (#1037)

Thread existing JSON schemas from prompt definitions through the
text-completion service to LLM backends' native structured output
APIs. When a prompt has response-type "json" and a strict-mode
compatible schema, the LLM constrains token selection at the logit
level to guarantee schema-valid output.

Wire-level changes:
- Add response_format and schema fields to TextCompletionRequest
- Update translator to encode/decode new fields
- Pass new fields through LlmService, TextCompletionClient, and
  PromptManager

Runtime schema compatibility checker:
- New is_strict_mode_compatible() utility validates schemas against
  LLM provider constraints (additionalProperties, required fields,
  no unsupported constraints, no open-ended objects)
- Per-prompt eligibility decision: compliant schemas use structured
  output, non-compliant schemas fall back to free-text + post-hoc
  validation

LLM backend implementations:
- OpenAI: response_format with json_schema, variant-aware top-level
  array rejection (openai variant blocks, llama/vllm variants allow)
- New vllm variant for the OpenAI backend
- vLLM (dedicated): response_format in raw HTTP body
- Ollama: format=<schema> parameter
- Claude: tool-use trick (forced tool call with schema as input_schema)
- Mistral: native json_schema response_format
- Llamafile, LM Studio: OpenAI SDK response_format
- Azure OpenAI: AzureOpenAI SDK response_format
- Azure serverless: response_format in raw HTTP body
- TGI: response_format in raw HTTP body
- VertexAI Gemini: response_mime_type + response_schema
- VertexAI Claude: tool-use trick
- Google AI Studio: response_mime_type + response_schema
- Bedrock, Cohere: signature-only (no structured output yet)

Post-hoc jsonschema.validate() retained as defence-in-depth.

Tech spec added: docs/tech-specs/structured-output.md

Update tests
This commit is contained in:
cybermaggedon 2026-07-10 15:28:56 +01:00 committed by GitHub
parent f106ae2103
commit 9136526863
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1089 additions and 71 deletions

View file

@ -109,7 +109,10 @@ class Processor(LlmService):
return self.generation_configs[cache_key]
async def generate_content(self, system, prompt, model=None, temperature=None):
async def generate_content(
self, system, prompt, model=None, temperature=None,
response_format=None, schema=None,
):
# Use provided model or fall back to default
model_name = model or self.default_model
@ -123,6 +126,14 @@ class Processor(LlmService):
# Set system instruction per request (can't be cached)
generation_config.system_instruction = system
if response_format == "json" and schema is not None:
generation_config.response_mime_type = "application/json"
generation_config.response_schema = schema
logger.debug("Structured output enabled (Gemini)")
else:
generation_config.response_mime_type = "text/plain"
generation_config.response_schema = None
try:
response = self.client.models.generate_content(
@ -174,7 +185,10 @@ class Processor(LlmService):
"""Google AI Studio supports streaming"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
async def generate_content_stream(
self, system, prompt, model=None, temperature=None,
response_format=None, schema=None,
):
"""Stream content generation from Google AI Studio"""
model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature

View file

@ -166,7 +166,10 @@ class Processor(LlmService):
return self.generation_configs[cache_key]
async def generate_content(self, system, prompt, model=None, temperature=None):
async def generate_content(
self, system, prompt, model=None, temperature=None,
response_format=None, schema=None,
):
# Use provided model or fall back to default
model_name = model or self.default_model
@ -182,6 +185,22 @@ class Processor(LlmService):
logger.debug(f"Sending request to Anthropic model '{model_name}'...")
client = self._get_anthropic_client()
kwargs = {}
use_tool_extract = False
if response_format == "json" and schema is not None:
kwargs["tools"] = [{
"name": "structured_response",
"description": "Return the structured response",
"input_schema": schema,
}]
kwargs["tool_choice"] = {
"type": "tool",
"name": "structured_response",
}
use_tool_extract = True
logger.debug("Structured output enabled (tool-use)")
response = client.messages.create(
model=model_name,
system=system,
@ -190,10 +209,21 @@ class Processor(LlmService):
temperature=effective_temperature,
top_p=self.api_params['top_p'],
top_k=self.api_params['top_k'],
**kwargs,
)
if use_tool_extract:
import json
tool_block = next(
(b for b in response.content if b.type == "tool_use"),
None,
)
text = json.dumps(tool_block.input) if tool_block else response.content[0].text
else:
text = response.content[0].text
resp = LlmResult(
text=response.content[0].text,
text=text,
in_token=response.usage.input_tokens,
out_token=response.usage.output_tokens,
model=model_name
@ -206,6 +236,14 @@ class Processor(LlmService):
# Set system instruction per request (can't be cached)
generation_config.system_instruction = system
if response_format == "json" and schema is not None:
generation_config.response_mime_type = "application/json"
generation_config.response_schema = schema
logger.debug("Structured output enabled (Gemini)")
else:
generation_config.response_mime_type = "text/plain"
generation_config.response_schema = None
response = self.client.models.generate_content(
model=model_name,
config=generation_config,
@ -248,7 +286,10 @@ class Processor(LlmService):
"""VertexAI supports streaming for both Gemini and Claude models"""
return True
async def generate_content_stream(self, system, prompt, model=None, temperature=None):
async def generate_content_stream(
self, system, prompt, model=None, temperature=None,
response_format=None, schema=None,
):
"""
Stream content generation from VertexAI (Gemini or Claude).
Yields LlmChunk objects with is_final=True on the last chunk.