mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-13 23:32:11 +02:00
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:
parent
f106ae2103
commit
9136526863
27 changed files with 1089 additions and 71 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue