mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-10 03:41:36 -10:00
- The rebase keeps 0.1.31's requirements.txt install in setup.py and the Dockerfile: jsonschema is not added. - serve/structured.py imports jsonschema on the first json_schema request, not at server start (a server without it started fine before #334 and still does). json_object never needs it; json_schema without it is checked to be one JSON object, and the server window says once how to get full schema validation. - test_structured: the two schema-violation tests skip without jsonschema; new tests for the object-only fallback and for "import serve.server" not importing jsonschema. docs/DETAILS.md says it is optional.
148 lines
6.3 KiB
Python
148 lines
6.3 KiB
Python
"""JSON response formats at the HTTP boundary: prompt once, validate before delivery.
|
|
|
|
The native engine has no grammar decoder. Failed generations are errors, never
|
|
silently retried or returned as successful structured output.
|
|
|
|
`jsonschema` is optional (no new hard dependency of the server): json_object needs only the standard library, and
|
|
json_schema is checked against its schema when the package is installed - without it, only that the answer is one
|
|
JSON object, and the server says so once.
|
|
"""
|
|
import json
|
|
import threading
|
|
|
|
|
|
class StructuredOutputError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class _ObjectOnly:
|
|
"""The validator for json_object, and for json_schema without jsonschema: one JSON object."""
|
|
|
|
def iter_errors(self, value):
|
|
if not isinstance(value, dict):
|
|
yield _ObjectError()
|
|
|
|
|
|
class _ObjectError:
|
|
absolute_path = ()
|
|
message = "the answer is not a JSON object"
|
|
|
|
|
|
_jsonschema = None # (validators, SchemaError, Registry, NoSuchResource), False when not installed
|
|
_jsonschema_lock = threading.Lock()
|
|
|
|
|
|
def jsonschema_modules():
|
|
"""jsonschema, imported on first use; None when it is not installed (said once, in the server window)."""
|
|
global _jsonschema
|
|
with _jsonschema_lock:
|
|
if _jsonschema is None:
|
|
try:
|
|
from jsonschema import validators
|
|
from jsonschema.exceptions import SchemaError
|
|
from referencing import Registry
|
|
from referencing.exceptions import NoSuchResource
|
|
_jsonschema = (validators, SchemaError, Registry, NoSuchResource)
|
|
except ImportError:
|
|
_jsonschema = False
|
|
print('[strata] response_format json_schema: the Python package jsonschema is not installed, so '
|
|
'answers are only checked to be one JSON object (python -m pip install "jsonschema>=4.23,<5")',
|
|
flush=True)
|
|
return _jsonschema or None
|
|
|
|
|
|
def prepare_format(response_format, messages):
|
|
if response_format is None:
|
|
return messages, None
|
|
if not isinstance(response_format, dict):
|
|
raise ValueError("response_format must be an object")
|
|
kind = response_format.get("type")
|
|
if kind == "text":
|
|
return messages, None
|
|
if kind == "json_object":
|
|
schema = {"type": "object"}
|
|
modules = None # the standard library is enough for "one JSON object"
|
|
elif kind == "json_schema":
|
|
spec = response_format.get("json_schema")
|
|
if not isinstance(spec, dict) or not isinstance(spec.get("schema"), dict):
|
|
raise ValueError("response_format.json_schema needs a schema object")
|
|
if not isinstance(spec.get("name"), str) or not spec["name"]:
|
|
raise ValueError("response_format.json_schema needs a name")
|
|
if "strict" in spec and not isinstance(spec["strict"], bool):
|
|
raise ValueError("response_format.json_schema.strict must be boolean")
|
|
schema = spec["schema"]
|
|
if schema.get("type") != "object":
|
|
raise ValueError("response_format schema must have type object at its root")
|
|
modules = jsonschema_modules()
|
|
else:
|
|
raise ValueError("response_format.type must be text, json_object or json_schema")
|
|
|
|
def check_refs(node):
|
|
if isinstance(node, dict):
|
|
for key, value in node.items():
|
|
if key in ("$ref", "$dynamicRef") and isinstance(value, str) and not value.startswith("#"):
|
|
raise ValueError("response_format supports only local schema references (#...)")
|
|
check_refs(value)
|
|
elif isinstance(node, list):
|
|
for value in node:
|
|
check_refs(value)
|
|
check_refs(schema)
|
|
if modules is None:
|
|
validator = _ObjectOnly()
|
|
else:
|
|
validators, SchemaError, Registry, NoSuchResource = modules
|
|
|
|
def no_remote(uri):
|
|
raise NoSuchResource(ref=uri)
|
|
|
|
try:
|
|
cls = validators.validator_for(schema)
|
|
cls.check_schema(schema)
|
|
validator = cls(schema, registry=Registry(retrieve=no_remote))
|
|
except SchemaError as exc:
|
|
raise ValueError(f"invalid response_format schema: {exc.message}") from exc
|
|
directive = ("OUTPUT FORMAT REQUIREMENT: Return exactly one JSON object matching the JSON Schema below. "
|
|
"No Markdown, headings, code fences, commentary, or text outside the JSON. "
|
|
"Use every required field, correct types, and only allowed fields. "
|
|
"Put all requested writing inside the appropriate JSON string fields.\nJSON Schema:\n" +
|
|
json.dumps(schema, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
|
|
messages = [dict(message) for message in messages]
|
|
if messages and messages[0].get("role") == "system":
|
|
content = messages[0].get("content") or ""
|
|
if isinstance(content, list):
|
|
messages[0]["content"] = content + [{"type": "text", "text": directive}]
|
|
else:
|
|
messages[0]["content"] = content + "\n\n" + directive
|
|
else:
|
|
messages.insert(0, {"role": "system", "content": directive})
|
|
return messages, validator
|
|
|
|
|
|
def validated_json(text, validator, finish):
|
|
def pairs(items):
|
|
obj = {}
|
|
for key, value in items:
|
|
if key in obj:
|
|
raise ValueError(f"duplicate JSON key: {key}")
|
|
obj[key] = value
|
|
return obj
|
|
|
|
def constant(value):
|
|
raise ValueError(f"invalid JSON constant: {value}")
|
|
|
|
if finish != "stop":
|
|
raise StructuredOutputError(f"structured output was incomplete (finish_reason={finish}); increase the output budget")
|
|
try:
|
|
value = json.loads(text or "", object_pairs_hook=pairs, parse_constant=constant)
|
|
canonical = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
except (ValueError, TypeError) as exc:
|
|
raise StructuredOutputError(f"model did not return valid JSON: {exc}") from exc
|
|
try:
|
|
error = next(validator.iter_errors(value), None)
|
|
except Exception as exc:
|
|
raise StructuredOutputError(f"could not validate structured output: {exc}") from exc
|
|
if error is not None:
|
|
path = "/" + "/".join(str(part) for part in error.absolute_path)
|
|
raise StructuredOutputError(f"model output failed the JSON Schema at {path}: {error.message}")
|
|
return canonical
|