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

@ -57,7 +57,8 @@ container-bedrock container-vertexai \
container-hf container-ocr \ container-hf container-ocr \
container-unstructured container-mcp container-unstructured container-mcp
some-containers: container-base container-flow container-unstructured some-containers: container-base container-flow
# container-unstructured
push: push:
${DOCKER} push ${CONTAINER_BASE}/trustgraph-base:${VERSION} ${DOCKER} push ${CONTAINER_BASE}/trustgraph-base:${VERSION}

View file

@ -0,0 +1,263 @@
---
layout: default
title: "Structured Output: LLM-Native JSON Schema Enforcement"
parent: "Tech Specs"
---
# Structured Output: LLM-Native JSON Schema Enforcement
## Problem / Opportunity
TrustGraph's knowledge-graph pipeline relies on LLMs to produce
structured JSON output — entity extractions, relationship triples,
topic classifications, and other schema-governed artefacts. Today,
the correct structure is requested via natural-language instructions
embedded in the prompt template: the prompt describes the expected
JSON shape, and the system parses the LLM's free-text response,
hoping it conforms.
This approach has several weaknesses:
1. **Fragile parsing.** LLM responses may include markdown fencing,
preamble text, trailing commentary, or minor schema violations
(missing fields, wrong types, extra keys). Every consumer must
tolerate or work around these deviations, adding defensive code
and retry logic.
2. **Wasted tokens and latency.** A significant portion of each
prompt is spent describing the output format in prose. When the
model deviates, retries consume additional tokens and add
end-to-end latency.
3. **Silent data-quality issues.** Malformed responses that pass
lenient parsing can inject bad data into the knowledge graph —
wrong types, truncated lists, invented field names — without
raising errors.
4. **Untapped LLM capability.** Most modern LLMs (OpenAI, Google
Gemini, Anthropic Claude, Ollama-hosted models via llama.cpp)
support *structured output* or *guided decoding*: the caller
supplies a JSON schema and the model constrains token selection at
the logit level to guarantee schema-valid output. TrustGraph
already defines the required JSON schemas inside its prompt
definitions but does not pass them through to the LLM backend.
### Opportunity
By threading the existing JSON schemas from prompt definitions
through the text-completion service to each LLM backend's native
structured-output API, TrustGraph can:
- **Guarantee valid output** on every call — no parsing heuristics,
no retries for format errors.
- **Reduce prompt size** by removing prose format instructions that
the schema makes redundant.
- **Improve data quality** in the knowledge graph by eliminating an
entire class of silent ingestion errors.
- **Simplify service code** by removing defensive JSON extraction and
validation logic from every consumer.
## Scope
Prompt definitions declare a `response-type` of `"text"`, `"json"`,
or `"jsonl"`. Structured output applies only to prompts that produce
machine-readable output (`"json"` and `"jsonl"`).
JSONL presents a compatibility challenge: LLM structured-output APIs
enforce a single top-level JSON schema, but JSONL prompts ask the
model to emit one JSON object per line — a format that is not itself
valid JSON. Converting JSONL prompts to request a JSON array would
conflict with the prompt text and sacrifice truncation resilience
(partial JSONL is recoverable line-by-line; a truncated array is
broken JSON).
This spec takes a three-phase approach:
- **Phase 1** — plumb schemas through to LLM backends with automatic
compatibility detection; non-compliant schemas fall back to the
current free-text path.
- **Phase 2** — fix up non-compliant schemas so more prompts benefit.
- **Phase 3** — address JSONL prompts.
---
## Phase 1 — Structured Output with Automatic Fallback
### Design
Phase 1 threads the JSON schema from the prompt definition through
the text-completion service to the LLM backend's native
structured-output API. Only prompts with `response-type: "json"` are
candidates.
Not all existing schemas are compatible with LLM structured-output
APIs. Rather than require schema changes up front, Phase 1 includes
a **runtime compatibility check**: if a schema passes, structured
output is used; if not, the prompt falls back to the current
free-text path with post-hoc validation. This makes the feature
safe to deploy immediately.
### Strict-Mode Schema Requirements
LLM providers impose constraints beyond standard JSON Schema
validation. A schema is considered strict-mode compatible when:
- Every `object` has `additionalProperties: false`.
- Every property defined in `properties` appears in `required`.
Optional fields use a nullable type (e.g. `"type": ["string", "null"]`)
instead of omitting the key from `required`.
- No `minimum`, `maximum`, `minLength`, `maxLength`, or `pattern`
constraints (unsupported by most providers' constrained decoders).
- No open-ended objects (`{"type": "object"}` without `properties`).
- A schema is present and non-null.
### Runtime Compatibility Check
`PromptManager` (or a shared utility) inspects each schema at load
time against the strict-mode rules above. The result is a boolean
flag per prompt: `structured_output_eligible`.
- **Eligible**`response_format` and `schema` are set on the
`TextCompletionRequest`; the LLM enforces the schema at generation
time.
- **Not eligible** — request is sent without schema fields; the
current free-text parsing and `jsonschema.validate()` path is used.
This is a per-prompt decision, not a global switch.
### Text-Completion Request Changes
`TextCompletionRequest` gains two optional fields:
```
TextCompletionRequest:
system: str
prompt: str
streaming: bool
response_format: str | None # "json" or None (default)
schema: dict | None # JSON Schema object or None
```
When `response_format` is `"json"` and `schema` is provided, the LLM
backend MUST use its native structured-output mechanism. When either
field is absent or null, behaviour is unchanged.
### LLM Backend Mapping
Each backend maps `response_format` + `schema` to its provider's
native API:
| Backend | API mechanism |
|------------|-------------------------------------------------------|
| OpenAI | `response_format={"type": "json_schema", "json_schema": {"name": "...", "schema": ...}}` |
| Claude | `tool_use` with a single tool whose `input_schema` is the target schema, or the `response_format` parameter when available |
| Gemini | `response_mime_type="application/json"` + `response_schema=...` |
| Ollama | `format="json"` + schema in the `format` field (llama.cpp guided decoding) |
| Llamafile | `response_format={"type": "json_object"}` + schema |
Backends that do not support schema-level enforcement (e.g. older
Ollama versions) fall back to `response_format=json` without a schema
and rely on post-hoc validation.
### Current Prompt Compatibility
Of the nine `response-type: "json"` prompts, two are strict-mode
compatible today:
| Prompt | Status | Issue |
|--------------------------|-----------|------------------------------------|
| `schema-selection` | Ready | — |
| `supervisor-decompose` | Ready | — |
| `plan-create` | Fixable | Optional fields not in `required` |
| `graphql-generation` | Blocked | Open-ended `variables` object; `minimum`/`maximum` on `confidence` |
| `plan-step-execute` | Blocked | Open-ended `arguments` object |
| `diagnose-structured-data` | No schema | — |
| `diagnose-xml` | No schema | — |
| `diagnose-json` | No schema | — |
| `diagnose-csv` | No schema | — |
### What Does Not Change
- Prompt templates and their text content.
- The `"text"` and `"jsonl"` response-type paths.
- The `TextCompletionResponse` schema.
- Post-hoc validation (retained as a defence-in-depth measure).
---
## Phase 2 — Schema Remediation
Phase 2 expands structured-output coverage by fixing schemas that
failed the Phase 1 compatibility check.
### Fixable Schemas
**`plan-create`** — `tool_hint` and `depends_on` are optional
(present in `properties` but absent from `required`). Fix: add both
to `required` and change their types to nullable:
```json
"tool_hint": {"type": ["string", "null"]},
"depends_on": {
"type": ["array", "null"],
"items": {"type": "integer"}
}
```
### Schemas Requiring Design Decisions
**`graphql-generation`** — Two issues:
- `variables` is an open-ended object (`"additionalProperties": true`)
with no fixed properties. Constrained decoding cannot handle
arbitrary keys. Options: remove `variables` from the schema and
accept it as free-form text within a wrapper, or restructure as a
JSON-encoded string field.
- `confidence` uses `"minimum": 0.0, "maximum": 1.0`. Fix: remove
the numeric bounds; accept any number and clamp in application code
if needed.
**`plan-step-execute`** — `arguments` is an open-ended object with no
fixed properties. Same constraint as `graphql-generation.variables`.
### Missing Schemas
The four `diagnose-*` prompts have `response-type: "json"` but no
schema. Adding schemas for these prompts would bring them into
structured-output scope. This requires defining the expected
response shape for each diagnostic prompt.
---
## Phase 3 (Future) — Structured Output for JSONL Prompts
JSONL prompts ask the LLM to emit multiple JSON objects, one per
line. Each object is validated individually against the prompt's
schema. The current approach is tolerant of truncation and
malformed lines — useful properties for large extractions.
### Options
**Option A — Array wrapper.** Change the prompt text to request a
JSON array instead of JSONL. Wrap the schema as
`{"type": "array", "items": <existing-schema>}` and use structured
output. Trade-off: loses line-by-line truncation resilience; requires
updating every JSONL prompt template.
**Option B — Structured output per chunk.** Split the input so each
text-completion call produces a single JSON object, then aggregate.
Trade-off: more LLM calls; higher latency and cost; may not suit
prompts that extract variable-length lists from a single chunk.
**Option C — Hybrid.** Use structured output with the array-wrapped
schema but retain the post-hoc JSONL parser as a fallback for
backends that do not support structured output or when the response
is truncated. Trade-off: two code paths to maintain.
**Option D — Status quo.** Leave JSONL prompts on the free-text path
with post-hoc validation. Structured output for `"json"` prompts
already covers the most schema-sensitive cases; JSONL extraction is
inherently more tolerant of partial results.
Phase 3 design will be selected after earlier phases are deployed and
real-world structured-output behaviour is observed across backends.

View file

@ -34,7 +34,7 @@ class TestPromptStreaming:
" of", " artificial", " intelligence", "." " of", " artificial", " intelligence", "."
] ]
async def streaming_text_completion_stream(system, prompt, handler, timeout=600): async def streaming_text_completion_stream(system, prompt, handler, timeout=600, response_format=None, schema=None):
"""Simulate streaming text completion via text_completion_stream""" """Simulate streaming text completion via text_completion_stream"""
for i, chunk_text in enumerate(chunks): for i, chunk_text in enumerate(chunks):
response = TextCompletionResponse( response = TextCompletionResponse(
@ -58,7 +58,7 @@ class TestPromptStreaming:
model="test-model", model="test-model",
) )
async def non_streaming_text_completion(system, prompt, timeout=600): async def non_streaming_text_completion(system, prompt, timeout=600, response_format=None, schema=None):
"""Simulate non-streaming text completion""" """Simulate non-streaming text completion"""
full_text = "Machine learning is a field of artificial intelligence." full_text = "Machine learning is a field of artificial intelligence."
return TextCompletionResult( return TextCompletionResult(
@ -230,7 +230,7 @@ class TestPromptStreaming:
# Mock text completion client that raises an error # Mock text completion client that raises an error
text_completion_client = AsyncMock() text_completion_client = AsyncMock()
async def failing_stream(system, prompt, handler, timeout=600): async def failing_stream(system, prompt, handler, timeout=600, response_format=None, schema=None):
raise RuntimeError("Text completion error") raise RuntimeError("Text completion error")
text_completion_client.text_completion_stream = AsyncMock( text_completion_client.text_completion_stream = AsyncMock(
@ -316,7 +316,7 @@ class TestPromptStreaming:
# Mock text completion that sends empty chunks # Mock text completion that sends empty chunks
text_completion_client = AsyncMock() text_completion_client = AsyncMock()
async def empty_streaming(system, prompt, handler, timeout=600): async def empty_streaming(system, prompt, handler, timeout=600, response_format=None, schema=None):
# Send empty chunk followed by final marker # Send empty chunk followed by final marker
await handler(TextCompletionResponse( await handler(TextCompletionResponse(
response="", response="",

View file

@ -0,0 +1,268 @@
"""
Unit tests for schema_compatibility.is_strict_mode_compatible
"""
import pytest
from trustgraph.base.schema_compatibility import is_strict_mode_compatible
class TestIsStrictModeCompatible:
def test_none_schema(self):
assert is_strict_mode_compatible(None) is False
def test_empty_dict(self):
assert is_strict_mode_compatible({}) is True
def test_simple_string(self):
assert is_strict_mode_compatible({"type": "string"}) is True
def test_compliant_object(self):
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
},
"required": ["name", "age"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is True
def test_missing_additional_properties(self):
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}
assert is_strict_mode_compatible(schema) is False
def test_additional_properties_true(self):
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
"additionalProperties": True,
}
assert is_strict_mode_compatible(schema) is False
def test_property_not_in_required(self):
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"nickname": {"type": "string"},
},
"required": ["name"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is False
def test_open_ended_object_no_properties(self):
schema = {
"type": "object",
}
assert is_strict_mode_compatible(schema) is False
def test_implicit_object_with_properties_key(self):
schema = {
"properties": {
"x": {"type": "number"},
},
"required": ["x"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is True
def test_nested_object_compliant(self):
schema = {
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
},
"required": ["street"],
"additionalProperties": False,
},
},
"required": ["address"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is True
def test_nested_object_non_compliant(self):
schema = {
"type": "object",
"properties": {
"metadata": {
"type": "object",
},
},
"required": ["metadata"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is False
def test_array_with_compliant_items(self):
schema = {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
},
"required": ["id"],
"additionalProperties": False,
},
}
assert is_strict_mode_compatible(schema) is True
def test_array_with_non_compliant_items(self):
schema = {
"type": "array",
"items": {
"type": "object",
"properties": {"id": {"type": "integer"}},
},
}
assert is_strict_mode_compatible(schema) is False
def test_array_with_simple_items(self):
schema = {
"type": "array",
"items": {"type": "string"},
}
assert is_strict_mode_compatible(schema) is True
def test_oneof_all_compliant(self):
schema = {
"oneOf": [
{"type": "string"},
{"type": "integer"},
]
}
assert is_strict_mode_compatible(schema) is True
def test_oneof_with_non_compliant_branch(self):
schema = {
"oneOf": [
{"type": "string"},
{"type": "object"},
]
}
assert is_strict_mode_compatible(schema) is False
def test_anyof(self):
schema = {
"anyOf": [
{"type": "string"},
{"type": "number"},
]
}
assert is_strict_mode_compatible(schema) is True
def test_allof(self):
schema = {
"allOf": [
{
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a"],
"additionalProperties": False,
},
]
}
assert is_strict_mode_compatible(schema) is True
def test_unsupported_minimum(self):
schema = {"type": "integer", "minimum": 0}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_maximum(self):
schema = {"type": "integer", "maximum": 100}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_pattern(self):
schema = {"type": "string", "pattern": "^[a-z]+$"}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_min_length(self):
schema = {"type": "string", "minLength": 1}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_max_length(self):
schema = {"type": "string", "maxLength": 255}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_min_items(self):
schema = {"type": "array", "items": {"type": "string"}, "minItems": 1}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_max_items(self):
schema = {"type": "array", "items": {"type": "string"}, "maxItems": 10}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_exclusive_minimum(self):
schema = {"type": "number", "exclusiveMinimum": 0}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_min_max_properties(self):
schema = {
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a"],
"additionalProperties": False,
"minProperties": 1,
}
assert is_strict_mode_compatible(schema) is False
def test_unsupported_constraint_inside_nested_property(self):
schema = {
"type": "object",
"properties": {
"score": {"type": "integer", "minimum": 0, "maximum": 100},
},
"required": ["score"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is False
def test_nullable_property(self):
schema = {
"type": "object",
"properties": {
"name": {"type": ["string", "null"]},
},
"required": ["name"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is True
def test_realistic_compliant_schema(self):
schema = {
"type": "object",
"properties": {
"action": {"type": "string"},
"services": {
"type": "array",
"items": {"type": "string"},
},
},
"required": ["action", "services"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is True
def test_realistic_non_compliant_optional_field(self):
schema = {
"type": "object",
"properties": {
"action": {"type": "string"},
"reason": {"type": "string"},
},
"required": ["action"],
"additionalProperties": False,
}
assert is_strict_mode_compatible(schema) is False

View file

@ -49,4 +49,5 @@ from . keyword_index_client import KeywordIndexClientSpec, KeywordIndexClient
from . row_embeddings_query_client import RowEmbeddingsQueryClientSpec from . row_embeddings_query_client import RowEmbeddingsQueryClientSpec
from . collection_config_handler import CollectionConfigHandler from . collection_config_handler import CollectionConfigHandler
from . audit_publisher import AuditPublisher from . audit_publisher import AuditPublisher
from . schema_compatibility import is_strict_mode_compatible

View file

@ -126,6 +126,8 @@ class LlmService(FlowProcessor):
# Check if streaming is requested and supported # Check if streaming is requested and supported
streaming = getattr(request, 'streaming', False) streaming = getattr(request, 'streaming', False)
response_format = getattr(request, 'response_format', None)
schema = getattr(request, 'schema', None)
if streaming and self.supports_streaming(): if streaming and self.supports_streaming():
@ -136,7 +138,8 @@ class LlmService(FlowProcessor):
).time(): ).time():
async for chunk in self.generate_content_stream( async for chunk in self.generate_content_stream(
request.system, request.prompt, model, temperature request.system, request.prompt, model, temperature,
response_format=response_format, schema=schema,
): ):
await flow("response").send( await flow("response").send(
TextCompletionResponse( TextCompletionResponse(
@ -159,7 +162,8 @@ class LlmService(FlowProcessor):
).time(): ).time():
response = await self.generate_content( response = await self.generate_content(
request.system, request.prompt, model, temperature request.system, request.prompt, model, temperature,
response_format=response_format, schema=schema,
) )
await flow("response").send( await flow("response").send(
@ -215,7 +219,10 @@ class LlmService(FlowProcessor):
""" """
return False return False
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,
):
""" """
Override in subclass to implement streaming. Override in subclass to implement streaming.
Should yield LlmChunk objects. Should yield LlmChunk objects.

View file

@ -0,0 +1,90 @@
import logging
logger = logging.getLogger(__name__)
def is_strict_mode_compatible(schema):
"""
Check whether a JSON schema is compatible with LLM structured-output
strict mode. Returns True if the schema can be passed directly to
providers like OpenAI, vLLM, etc.
"""
if schema is None:
return False
try:
_check_node(schema)
return True
except _IncompatibleSchema as e:
logger.debug("Schema not strict-mode compatible: %s", e)
return False
class _IncompatibleSchema(Exception):
pass
def _check_node(node):
if not isinstance(node, dict):
return
node_type = node.get("type")
if node_type == "object" or (
node_type is None and "properties" in node
):
_check_object(node)
if node_type == "array":
items = node.get("items")
if items:
_check_node(items)
for keyword in ("oneOf", "anyOf", "allOf"):
for child in node.get(keyword, []):
_check_node(child)
_check_unsupported_constraints(node)
def _check_object(node):
props = node.get("properties")
if props is None:
raise _IncompatibleSchema(
"object without properties (open-ended)"
)
if node.get("additionalProperties") is not False:
raise _IncompatibleSchema(
"object missing additionalProperties: false"
)
required = set(node.get("required", []))
for key in props:
if key not in required:
raise _IncompatibleSchema(
f"property '{key}' not in required"
)
for value in props.values():
_check_node(value)
UNSUPPORTED_KEYWORDS = {
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
"minLength", "maxLength", "pattern",
"minItems", "maxItems",
"minProperties", "maxProperties",
}
def _check_unsupported_constraints(node):
found = UNSUPPORTED_KEYWORDS & node.keys()
if found:
raise _IncompatibleSchema(
f"unsupported constraints: {', '.join(sorted(found))}"
)

View file

@ -14,11 +14,15 @@ class TextCompletionResult:
class TextCompletionClient(RequestResponse): class TextCompletionClient(RequestResponse):
async def text_completion(self, system, prompt, timeout=600): async def text_completion(
self, system, prompt, timeout=600,
response_format=None, schema=None,
):
resp = await self.request( resp = await self.request(
TextCompletionRequest( TextCompletionRequest(
system = system, prompt = prompt, streaming = False system=system, prompt=prompt, streaming=False,
response_format=response_format, schema=schema,
), ),
timeout=timeout timeout=timeout
) )
@ -35,6 +39,7 @@ class TextCompletionClient(RequestResponse):
async def text_completion_stream( async def text_completion_stream(
self, system, prompt, handler, timeout=600, self, system, prompt, handler, timeout=600,
response_format=None, schema=None,
): ):
""" """
Streaming text completion. `handler` is an async callable invoked Streaming text completion. `handler` is an async callable invoked
@ -54,7 +59,8 @@ class TextCompletionClient(RequestResponse):
final = await self.request( final = await self.request(
TextCompletionRequest( TextCompletionRequest(
system = system, prompt = prompt, streaming = True system=system, prompt=prompt, streaming=True,
response_format=response_format, schema=schema,
), ),
recipient=on_chunk, recipient=on_chunk,
timeout=timeout, timeout=timeout,

View file

@ -10,14 +10,21 @@ class TextCompletionRequestTranslator(MessageTranslator):
return TextCompletionRequest( return TextCompletionRequest(
system=data["system"], system=data["system"],
prompt=data["prompt"], prompt=data["prompt"],
streaming=data.get("streaming", False) streaming=data.get("streaming", False),
response_format=data.get("response_format"),
schema=data.get("schema"),
) )
def encode(self, obj: TextCompletionRequest) -> Dict[str, Any]: def encode(self, obj: TextCompletionRequest) -> Dict[str, Any]:
return { result = {
"system": obj.system, "system": obj.system,
"prompt": obj.prompt "prompt": obj.prompt,
} }
if obj.response_format is not None:
result["response_format"] = obj.response_format
if obj.schema is not None:
result["schema"] = obj.schema
return result
class TextCompletionResponseTranslator(MessageTranslator): class TextCompletionResponseTranslator(MessageTranslator):

View file

@ -11,7 +11,9 @@ from ..core.primitives import Error
class TextCompletionRequest: class TextCompletionRequest:
system: str = "" system: str = ""
prompt: str = "" prompt: str = ""
streaming: bool = False # Default false for backward compatibility streaming: bool = False
response_format: str | None = None
schema: dict | None = None
@dataclass @dataclass
class TextCompletionResponse: class TextCompletionResponse:

View file

@ -247,7 +247,10 @@ class Processor(LlmService):
return self.model_variants[cache_key] return self.model_variants[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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -311,7 +314,10 @@ class Processor(LlmService):
"""Bedrock supports streaming""" """Bedrock supports streaming"""
return True 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 Bedrock""" """Stream content generation from Bedrock"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature

View file

@ -55,7 +55,10 @@ class Processor(LlmService):
self.max_output = max_output self.max_output = max_output
self.default_model = model self.default_model = model
def build_prompt(self, system, content, temperature=None, stream=False, model=None): def build_prompt(
self, system, content, temperature=None, stream=False,
model=None, response_format=None, schema=None,
):
# Use provided temperature or fall back to default # Use provided temperature or fall back to default
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
model_name = model or self.default_model model_name = model or self.default_model
@ -79,6 +82,17 @@ class Processor(LlmService):
data["stream"] = True data["stream"] = True
data["stream_options"] = {"include_usage": True} data["stream_options"] = {"include_usage": True}
if response_format == "json" and schema is not None:
data["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled")
body = json.dumps(data) body = json.dumps(data)
return body return body
@ -109,7 +123,10 @@ class Processor(LlmService):
return result return result
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -125,7 +142,9 @@ class Processor(LlmService):
system, system,
prompt, prompt,
effective_temperature, effective_temperature,
model=model_name model=model_name,
response_format=response_format,
schema=schema,
) )
response = self.call_llm(prompt) response = self.call_llm(prompt)
@ -169,7 +188,10 @@ class Processor(LlmService):
"""Azure serverless endpoints support streaming""" """Azure serverless endpoints support streaming"""
return True 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 Azure serverless endpoint""" """Stream content generation from Azure serverless endpoint"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -178,7 +200,11 @@ class Processor(LlmService):
logger.debug(f"Using temperature: {effective_temperature}") logger.debug(f"Using temperature: {effective_temperature}")
try: try:
body = self.build_prompt(system, prompt, effective_temperature, stream=True, model=model_name) body = self.build_prompt(
system, prompt, effective_temperature, stream=True,
model=model_name, response_format=response_format,
schema=schema,
)
url = self.endpoint url = self.endpoint
api_key = self.token api_key = self.token

View file

@ -62,7 +62,10 @@ class Processor(LlmService):
azure_endpoint = endpoint, azure_endpoint = endpoint,
) )
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -76,6 +79,18 @@ class Processor(LlmService):
try: try:
kwargs = {}
if response_format == "json" and schema is not None:
kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled")
resp = self.openai.chat.completions.create( resp = self.openai.chat.completions.create(
model=model_name, model=model_name,
messages=[ messages=[
@ -92,6 +107,7 @@ class Processor(LlmService):
temperature=effective_temperature, temperature=effective_temperature,
max_completion_tokens=self.max_output, max_completion_tokens=self.max_output,
top_p=1, top_p=1,
**kwargs,
) )
inputtokens = resp.usage.prompt_tokens inputtokens = resp.usage.prompt_tokens
@ -129,7 +145,10 @@ class Processor(LlmService):
"""Azure OpenAI supports streaming""" """Azure OpenAI supports streaming"""
return True 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 Azure OpenAI. Stream content generation from Azure OpenAI.
Yields LlmChunk objects with is_final=True on the last chunk. Yields LlmChunk objects with is_final=True on the last chunk.

View file

@ -48,7 +48,10 @@ class Processor(LlmService):
logger.info("Claude LLM service initialized") logger.info("Claude LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -60,11 +63,27 @@ class Processor(LlmService):
try: try:
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 = message = self.claude.messages.create( response = message = self.claude.messages.create(
model=model_name, model=model_name,
max_tokens=self.max_output, max_tokens=self.max_output,
temperature=effective_temperature, temperature=effective_temperature,
system = system, system=system,
messages=[ messages=[
{ {
"role": "user", "role": "user",
@ -75,10 +94,22 @@ class Processor(LlmService):
} }
] ]
} }
] ],
**kwargs,
) )
resp = response.content[0].text if use_tool_extract:
import json
tool_block = next(
(b for b in response.content if b.type == "tool_use"),
None,
)
if tool_block:
resp = json.dumps(tool_block.input)
else:
resp = response.content[0].text
else:
resp = response.content[0].text
inputtokens = response.usage.input_tokens inputtokens = response.usage.input_tokens
outputtokens = response.usage.output_tokens outputtokens = response.usage.output_tokens
logger.debug(f"LLM response: {resp}") logger.debug(f"LLM response: {resp}")
@ -110,7 +141,10 @@ class Processor(LlmService):
"""Claude/Anthropic supports streaming""" """Claude/Anthropic supports streaming"""
return True 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 Claude""" """Stream content generation from Claude"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature

View file

@ -46,7 +46,10 @@ class Processor(LlmService):
logger.info("Cohere LLM service initialized") logger.info("Cohere LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -104,7 +107,10 @@ class Processor(LlmService):
"""Cohere supports streaming""" """Cohere supports streaming"""
return True 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 Cohere""" """Stream content generation from Cohere"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature

View file

@ -50,7 +50,10 @@ class Processor(LlmService):
logger.info("Llamafile LLM service initialized") logger.info("Llamafile LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -64,6 +67,18 @@ class Processor(LlmService):
try: try:
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled")
else:
fmt = {"type": "text"}
resp = self.openai.chat.completions.create( resp = self.openai.chat.completions.create(
model=model_name, model=model_name,
messages=[ messages=[
@ -74,9 +89,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={ response_format=fmt,
"type": "text"
}
) )
inputtokens = resp.usage.prompt_tokens inputtokens = resp.usage.prompt_tokens
@ -106,7 +119,10 @@ class Processor(LlmService):
"""LlamaFile supports streaming""" """LlamaFile supports streaming"""
return True 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 LlamaFile""" """Stream content generation from LlamaFile"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -117,6 +133,18 @@ class Processor(LlmService):
prompt = system + "\n\n" + prompt prompt = system + "\n\n" + prompt
try: try:
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled (streaming)")
else:
fmt = {"type": "text"}
response = self.openai.chat.completions.create( response = self.openai.chat.completions.create(
model=model_name, model=model_name,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
@ -125,7 +153,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={"type": "text"}, response_format=fmt,
stream=True, stream=True,
stream_options={"include_usage": True} stream_options={"include_usage": True}
) )

View file

@ -50,7 +50,10 @@ class Processor(LlmService):
logger.info("LMStudio LLM service initialized") logger.info("LMStudio LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -66,6 +69,18 @@ class Processor(LlmService):
logger.debug(f"Prompt: {prompt}") logger.debug(f"Prompt: {prompt}")
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled")
else:
fmt = {"type": "text"}
resp = self.openai.chat.completions.create( resp = self.openai.chat.completions.create(
model=model_name, model=model_name,
messages=[ messages=[
@ -76,9 +91,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={ response_format=fmt,
"type": "text"
}
) )
logger.debug(f"Full response: {resp}") logger.debug(f"Full response: {resp}")
@ -110,7 +123,10 @@ class Processor(LlmService):
"""LM Studio supports streaming""" """LM Studio supports streaming"""
return True 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 LM Studio""" """Stream content generation from LM Studio"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -121,6 +137,18 @@ class Processor(LlmService):
prompt = system + "\n\n" + prompt prompt = system + "\n\n" + prompt
try: try:
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled (streaming)")
else:
fmt = {"type": "text"}
response = self.openai.chat.completions.create( response = self.openai.chat.completions.create(
model=model_name, model=model_name,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
@ -129,7 +157,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={"type": "text"}, response_format=fmt,
stream=True, stream=True,
stream_options={"include_usage": True} stream_options={"include_usage": True}
) )

View file

@ -48,7 +48,10 @@ class Processor(LlmService):
logger.info("Mistral LLM service initialized") logger.info("Mistral LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -62,6 +65,19 @@ class Processor(LlmService):
try: try:
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled")
else:
fmt = {"type": "text"}
resp = self.mistral.chat.complete( resp = self.mistral.chat.complete(
model=model_name, model=model_name,
messages=[ messages=[
@ -80,9 +96,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={ response_format=fmt,
"type": "text"
}
) )
inputtokens = resp.usage.prompt_tokens inputtokens = resp.usage.prompt_tokens
@ -120,7 +134,10 @@ class Processor(LlmService):
"""Mistral supports streaming""" """Mistral supports streaming"""
return True 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 Mistral""" """Stream content generation from Mistral"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -131,6 +148,19 @@ class Processor(LlmService):
prompt = system + "\n\n" + prompt prompt = system + "\n\n" + prompt
try: try:
if response_format == "json" and schema is not None:
fmt = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled (streaming)")
else:
fmt = {"type": "text"}
stream = self.mistral.chat.stream( stream = self.mistral.chat.stream(
model=model_name, model=model_name,
messages=[ messages=[
@ -149,7 +179,7 @@ class Processor(LlmService):
top_p=1, top_p=1,
frequency_penalty=0, frequency_penalty=0,
presence_penalty=0, presence_penalty=0,
response_format={"type": "text"} response_format=fmt,
) )
total_input_tokens = 0 total_input_tokens = 0

View file

@ -62,7 +62,10 @@ class Processor(LlmService):
else: else:
logger.warning(f"Failed to check Ollama model '{model_name}': {e}") logger.warning(f"Failed to check Ollama model '{model_name}': {e}")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -79,7 +82,12 @@ class Processor(LlmService):
try: try:
response = await self.llm.generate(model_name, prompt, options={'temperature': effective_temperature}) kwargs = {}
if response_format == "json" and schema is not None:
kwargs["format"] = schema
logger.debug("Structured output enabled")
response = await self.llm.generate(model_name, prompt, options={'temperature': effective_temperature}, **kwargs)
response_text = response['response'] response_text = response['response']
logger.debug("Sending response...") logger.debug("Sending response...")
@ -108,7 +116,10 @@ class Processor(LlmService):
"""Ollama supports streaming""" """Ollama supports streaming"""
return True 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 Ollama""" """Stream content generation from Ollama"""
model_name = model or self.default_model model_name = model or self.default_model
@ -123,11 +134,17 @@ class Processor(LlmService):
prompt = system + "\n\n" + prompt prompt = system + "\n\n" + prompt
try: try:
kwargs = {}
if response_format == "json" and schema is not None:
kwargs["format"] = schema
logger.debug("Structured output enabled (streaming)")
stream = await self.llm.generate( stream = await self.llm.generate(
model_name, model_name,
prompt, prompt,
options={'temperature': effective_temperature}, options={'temperature': effective_temperature},
stream=True stream=True,
**kwargs,
) )
total_input_tokens = 0 total_input_tokens = 0

View file

@ -82,7 +82,10 @@ class Processor(LlmService):
return self.variant.extract_content(message) return self.variant.extract_content(message)
return message.content return message.content
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,
):
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -96,6 +99,25 @@ class Processor(LlmService):
api_kwargs = self._build_kwargs(model_name, effective_temperature) api_kwargs = self._build_kwargs(model_name, effective_temperature)
if response_format == "json" and schema is not None:
is_top_level_array = schema.get("type") == "array"
if is_top_level_array and not self.variant.supports_top_level_array():
logger.debug(
"Variant %s does not support top-level array "
"schemas, falling back to free-text",
self.variant.name,
)
else:
api_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled")
messages = [ messages = [
{ {
"role": "user", "role": "user",
@ -160,7 +182,10 @@ class Processor(LlmService):
"""OpenAI supports streaming""" """OpenAI supports streaming"""
return True 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 OpenAI. Stream content generation from OpenAI.
Yields LlmChunk objects with is_final=True on the last chunk. Yields LlmChunk objects with is_final=True on the last chunk.
@ -176,6 +201,25 @@ class Processor(LlmService):
try: try:
api_kwargs = self._build_kwargs(model_name, effective_temperature) api_kwargs = self._build_kwargs(model_name, effective_temperature)
if response_format == "json" and schema is not None:
is_top_level_array = schema.get("type") == "array"
if is_top_level_array and not self.variant.supports_top_level_array():
logger.debug(
"Variant %s does not support top-level array "
"schemas, falling back to free-text (streaming)",
self.variant.name,
)
else:
api_kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
"strict": True,
},
}
logger.debug("Structured output enabled (streaming)")
messages = [ messages = [
{ {
"role": "user", "role": "user",

View file

@ -62,6 +62,10 @@ class Variant:
"""Extract thinking content from a streaming delta.""" """Extract thinking content from a streaming delta."""
return getattr(delta, "reasoning_content", None) return getattr(delta, "reasoning_content", None)
def supports_top_level_array(self):
"""Whether this provider accepts a top-level array JSON schema."""
return True
def create_completion(self, client, model, messages, **kwargs): def create_completion(self, client, model, messages, **kwargs):
"""Call the completions API. Override for non-standard SDKs.""" """Call the completions API. Override for non-standard SDKs."""
return client.chat.completions.create( return client.chat.completions.create(
@ -84,6 +88,9 @@ class OpenAIVariant(Variant):
token_param = "max_completion_tokens" token_param = "max_completion_tokens"
temperature_with_thinking = False temperature_with_thinking = False
def supports_top_level_array(self):
return False
def thinking_kwargs(self, effort): def thinking_kwargs(self, effort):
return {"reasoning_effort": effort} return {"reasoning_effort": effort}
@ -195,6 +202,12 @@ class LlamaVariant(Variant):
return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip() return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
class VllmVariant(LlamaVariant):
"""vLLM via OpenAI-compatible API. Supports full structured output."""
name = "vllm"
VARIANTS = { VARIANTS = {
"openai": OpenAIVariant, "openai": OpenAIVariant,
"deepseek": DeepSeekVariant, "deepseek": DeepSeekVariant,
@ -203,6 +216,7 @@ VARIANTS = {
"dashscope": DashScopeVariant, "dashscope": DashScopeVariant,
"glm": GlmVariant, "glm": GlmVariant,
"llama": LlamaVariant, "llama": LlamaVariant,
"vllm": VllmVariant,
} }
DEFAULT_VARIANT = "openai" DEFAULT_VARIANT = "openai"

View file

@ -51,7 +51,10 @@ class Processor(LlmService):
logger.info(f"Using TGI service at {base_url}") logger.info(f"Using TGI service at {base_url}")
logger.info("TGI LLM service initialized") logger.info("TGI LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -79,7 +82,17 @@ class Processor(LlmService):
], ],
"max_tokens": self.max_output, "max_tokens": self.max_output,
"temperature": effective_temperature, "temperature": effective_temperature,
} }
if response_format == "json" and schema is not None:
request["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled")
try: try:
@ -125,7 +138,10 @@ class Processor(LlmService):
"""TGI supports streaming""" """TGI supports streaming"""
return True 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 TGI""" """Stream content generation from TGI"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature

View file

@ -52,7 +52,10 @@ class Processor(LlmService):
logger.info(f"Using vLLM service at {base_url}") logger.info(f"Using vLLM service at {base_url}")
logger.info("vLLM LLM service initialized") logger.info("vLLM LLM service initialized")
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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -71,7 +74,17 @@ class Processor(LlmService):
"prompt": system + "\n\n" + prompt, "prompt": system + "\n\n" + prompt,
"max_tokens": self.max_output, "max_tokens": self.max_output,
"temperature": effective_temperature, "temperature": effective_temperature,
} }
if response_format == "json" and schema is not None:
request["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled")
try: try:
@ -127,7 +140,10 @@ class Processor(LlmService):
"""vLLM supports streaming""" """vLLM supports streaming"""
return True 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 vLLM""" """Stream content generation from vLLM"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature effective_temperature = temperature if temperature is not None else self.temperature
@ -148,6 +164,16 @@ class Processor(LlmService):
"stream_options": {"include_usage": True}, "stream_options": {"include_usage": True},
} }
if response_format == "json" and schema is not None:
request["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": schema,
},
}
logger.debug("Structured output enabled (streaming)")
try: try:
url = f"{self.base_url.rstrip('/')}/completions" url = f"{self.base_url.rstrip('/')}/completions"

View file

@ -155,7 +155,10 @@ class Processor(FlowProcessor):
# For streaming, we need to intercept LLM responses # For streaming, we need to intercept LLM responses
# and forward them as they arrive # and forward them as they arrive
async def llm_streaming(system, prompt): async def llm_streaming(
system, prompt,
response_format=None, schema=None,
):
logger.debug(f"System prompt: {system}") logger.debug(f"System prompt: {system}")
logger.debug(f"User prompt: {prompt}") logger.debug(f"User prompt: {prompt}")
@ -179,6 +182,8 @@ class Processor(FlowProcessor):
system=system, prompt=prompt, system=system, prompt=prompt,
handler=forward_chunks, handler=forward_chunks,
timeout=600, timeout=600,
response_format=response_format,
schema=schema,
) )
# Return empty string since we already sent all chunks # Return empty string since we already sent all chunks
@ -195,14 +200,19 @@ class Processor(FlowProcessor):
# Non-streaming path (original behavior) # Non-streaming path (original behavior)
usage = {} usage = {}
async def llm(system, prompt): async def llm(
system, prompt,
response_format=None, schema=None,
):
logger.debug(f"System prompt: {system}") logger.debug(f"System prompt: {system}")
logger.debug(f"User prompt: {prompt}") logger.debug(f"User prompt: {prompt}")
try: try:
result = await flow("text-completion-request").text_completion( result = await flow("text-completion-request").text_completion(
system = system, prompt = prompt, system=system, prompt=prompt,
response_format=response_format,
schema=schema,
) )
usage["in_token"] = result.in_token usage["in_token"] = result.in_token
usage["out_token"] = result.out_token usage["out_token"] = result.out_token

View file

@ -5,6 +5,8 @@ from jsonschema import validate
import re import re
import logging import logging
from trustgraph.base import is_strict_mode_compatible
# Module logger # Module logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -145,12 +147,24 @@ class PromptManager:
terms = self.terms | self.prompts[id].terms | input terms = self.terms | self.prompts[id].terms | input
resp_type = self.prompts[id].response_type resp_type = self.prompts[id].response_type
schema = self.prompts[id].schema
prompt = { prompt = {
"system": self.system_template.render(terms), "system": self.system_template.render(terms),
"prompt": self.render(id, input) "prompt": self.render(id, input),
} }
use_structured = (
resp_type == "json"
and schema is not None
and is_strict_mode_compatible(schema)
)
if use_structured:
logger.debug("Using structured output for prompt '%s'", id)
prompt["response_format"] = "json"
prompt["schema"] = schema
resp = await llm(**prompt) resp = await llm(**prompt)
if resp_type == "text": if resp_type == "text":

View file

@ -109,7 +109,10 @@ class Processor(LlmService):
return self.generation_configs[cache_key] 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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -123,6 +126,14 @@ class Processor(LlmService):
# Set system instruction per request (can't be cached) # Set system instruction per request (can't be cached)
generation_config.system_instruction = system 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: try:
response = self.client.models.generate_content( response = self.client.models.generate_content(
@ -174,7 +185,10 @@ class Processor(LlmService):
"""Google AI Studio supports streaming""" """Google AI Studio supports streaming"""
return True 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""" """Stream content generation from Google AI Studio"""
model_name = model or self.default_model model_name = model or self.default_model
effective_temperature = temperature if temperature is not None else self.temperature 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] 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 # Use provided model or fall back to default
model_name = model or self.default_model model_name = model or self.default_model
@ -182,6 +185,22 @@ class Processor(LlmService):
logger.debug(f"Sending request to Anthropic model '{model_name}'...") logger.debug(f"Sending request to Anthropic model '{model_name}'...")
client = self._get_anthropic_client() 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( response = client.messages.create(
model=model_name, model=model_name,
system=system, system=system,
@ -190,10 +209,21 @@ class Processor(LlmService):
temperature=effective_temperature, temperature=effective_temperature,
top_p=self.api_params['top_p'], top_p=self.api_params['top_p'],
top_k=self.api_params['top_k'], 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( resp = LlmResult(
text=response.content[0].text, text=text,
in_token=response.usage.input_tokens, in_token=response.usage.input_tokens,
out_token=response.usage.output_tokens, out_token=response.usage.output_tokens,
model=model_name model=model_name
@ -206,6 +236,14 @@ class Processor(LlmService):
# Set system instruction per request (can't be cached) # Set system instruction per request (can't be cached)
generation_config.system_instruction = system 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( response = self.client.models.generate_content(
model=model_name, model=model_name,
config=generation_config, config=generation_config,
@ -248,7 +286,10 @@ class Processor(LlmService):
"""VertexAI supports streaming for both Gemini and Claude models""" """VertexAI supports streaming for both Gemini and Claude models"""
return True 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). Stream content generation from VertexAI (Gemini or Claude).
Yields LlmChunk objects with is_final=True on the last chunk. Yields LlmChunk objects with is_final=True on the last chunk.