structguard 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """
2
+ StructGuard — Reliable structured outputs for production LLM applications.
3
+
4
+ from structguard import generate
5
+
6
+ incident = generate(llm=client, prompt="Summarize this incident.", schema=Incident)
7
+ """
8
+ from .core import GenerationResult, generate
9
+ from .exceptions import (
10
+ InvalidResponseError,
11
+ InvalidSchemaError,
12
+ JsonRepairError,
13
+ RetryLimitExceeded,
14
+ SchemaValidationError,
15
+ StructGuardError,
16
+ UnsupportedProviderError,
17
+ )
18
+ from .report import ValidationReport
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "generate",
24
+ "GenerationResult",
25
+ "ValidationReport",
26
+ "StructGuardError",
27
+ "SchemaValidationError",
28
+ "JsonRepairError",
29
+ "RetryLimitExceeded",
30
+ "UnsupportedProviderError",
31
+ "InvalidResponseError",
32
+ "InvalidSchemaError",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..exceptions import UnsupportedProviderError
6
+ from .anthropic_adapter import AnthropicAdapter
7
+ from .base import BaseAdapter, RawCompletion
8
+ from .gemini_adapter import GeminiAdapter
9
+ from .groq_adapter import GroqAdapter
10
+ from .ollama_adapter import OllamaAdapter
11
+ from .openai_adapter import OpenAIAdapter
12
+
13
+ _REGISTRY: list[type[BaseAdapter]] = [
14
+ OpenAIAdapter,
15
+ AnthropicAdapter,
16
+ GeminiAdapter,
17
+ GroqAdapter,
18
+ OllamaAdapter,
19
+ ]
20
+
21
+
22
+ def resolve_adapter(client: Any, default_model: str | None = None) -> BaseAdapter:
23
+ """Inspect `client` and return the matching adapter instance."""
24
+ for adapter_cls in _REGISTRY:
25
+ if adapter_cls.matches(client):
26
+ return adapter_cls(client, default_model=default_model)
27
+ raise UnsupportedProviderError(
28
+ "Could not determine LLM provider from the client object. "
29
+ "Pass an OpenAI, Anthropic, Gemini, Groq, or Ollama client instance.",
30
+ context={"client_type": type(client).__name__},
31
+ )
32
+
33
+
34
+ __all__ = ["BaseAdapter", "RawCompletion", "resolve_adapter",
35
+ "OpenAIAdapter", "AnthropicAdapter", "GeminiAdapter", "GroqAdapter", "OllamaAdapter"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .base import BaseAdapter, RawCompletion
6
+
7
+
8
+ class AnthropicAdapter(BaseAdapter):
9
+ provider_name = "Anthropic"
10
+
11
+ @classmethod
12
+ def matches(cls, client: Any) -> bool:
13
+ return type(client).__module__.startswith("anthropic")
14
+
15
+ def complete(self, prompt: str, *, model: Optional[str] = None,
16
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
17
+ model = model or self.default_model or "claude-sonnet-4-6"
18
+ response = self.client.messages.create(
19
+ model=model,
20
+ max_tokens=kwargs.pop("max_tokens", 1024),
21
+ system=system or "You respond with valid JSON only, matching the requested schema.",
22
+ messages=[{"role": "user", "content": prompt}],
23
+ **kwargs,
24
+ )
25
+ text = "".join(block.text for block in response.content if getattr(block, "type", None) == "text")
26
+ usage = getattr(response, "usage", None)
27
+ return RawCompletion(
28
+ text=text,
29
+ model=model,
30
+ provider=self.provider_name,
31
+ tokens_input=getattr(usage, "input_tokens", None) if usage else None,
32
+ tokens_output=getattr(usage, "output_tokens", None) if usage else None,
33
+ raw_response=response,
34
+ )
@@ -0,0 +1,42 @@
1
+ """
2
+ Universal LLM Adapter Layer — base interface.
3
+
4
+ Every provider adapter implements `.complete(prompt, model=None, **kwargs)`
5
+ and returns a `RawCompletion`, giving the rest of the SDK one shape to work
6
+ with regardless of provider.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+ from typing import Any, Optional
13
+
14
+
15
+ @dataclass
16
+ class RawCompletion:
17
+ text: str
18
+ model: str
19
+ provider: str
20
+ tokens_input: Optional[int] = None
21
+ tokens_output: Optional[int] = None
22
+ raw_response: Any = None
23
+
24
+
25
+ class BaseAdapter(ABC):
26
+ provider_name: str = "base"
27
+
28
+ def __init__(self, client: Any, default_model: Optional[str] = None):
29
+ self.client = client
30
+ self.default_model = default_model
31
+
32
+ @abstractmethod
33
+ def complete(self, prompt: str, *, model: Optional[str] = None,
34
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
35
+ """Send `prompt` to the provider and return a RawCompletion."""
36
+ raise NotImplementedError
37
+
38
+ @classmethod
39
+ @abstractmethod
40
+ def matches(cls, client: Any) -> bool:
41
+ """Return True if `client` is an instance this adapter knows how to drive."""
42
+ raise NotImplementedError
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .base import BaseAdapter, RawCompletion
6
+
7
+
8
+ class GeminiAdapter(BaseAdapter):
9
+ provider_name = "Gemini"
10
+
11
+ @classmethod
12
+ def matches(cls, client: Any) -> bool:
13
+ return type(client).__module__.startswith("google.generativeai")
14
+
15
+ def complete(self, prompt: str, *, model: Optional[str] = None,
16
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
17
+ model_name = model or self.default_model or "gemini-1.5-pro"
18
+ full_prompt = f"{system}\n\n{prompt}" if system else prompt
19
+
20
+ # google-generativeai's GenerativeModel is created per-model, so if the
21
+ # caller passed the module/client directly we instantiate here.
22
+ model_obj = self.client if hasattr(self.client, "generate_content") else self.client.GenerativeModel(model_name)
23
+ response = model_obj.generate_content(
24
+ full_prompt,
25
+ generation_config={"response_mime_type": "application/json"},
26
+ **kwargs,
27
+ )
28
+ usage = getattr(response, "usage_metadata", None)
29
+ return RawCompletion(
30
+ text=response.text,
31
+ model=model_name,
32
+ provider=self.provider_name,
33
+ tokens_input=getattr(usage, "prompt_token_count", None) if usage else None,
34
+ tokens_output=getattr(usage, "candidates_token_count", None) if usage else None,
35
+ raw_response=response,
36
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .base import BaseAdapter, RawCompletion
6
+
7
+
8
+ class GroqAdapter(BaseAdapter):
9
+ provider_name = "Groq"
10
+
11
+ @classmethod
12
+ def matches(cls, client: Any) -> bool:
13
+ return type(client).__module__.startswith("groq")
14
+
15
+ def complete(self, prompt: str, *, model: Optional[str] = None,
16
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
17
+ model = model or self.default_model or "llama-3.3-70b-versatile"
18
+ messages = []
19
+ if system:
20
+ messages.append({"role": "system", "content": system})
21
+ messages.append({"role": "user", "content": prompt})
22
+
23
+ response = self.client.chat.completions.create(
24
+ model=model,
25
+ messages=messages,
26
+ response_format={"type": "json_object"},
27
+ **kwargs,
28
+ )
29
+ choice = response.choices[0].message.content
30
+ usage = getattr(response, "usage", None)
31
+ return RawCompletion(
32
+ text=choice or "",
33
+ model=model,
34
+ provider=self.provider_name,
35
+ tokens_input=getattr(usage, "prompt_tokens", None) if usage else None,
36
+ tokens_output=getattr(usage, "completion_tokens", None) if usage else None,
37
+ raw_response=response,
38
+ )
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .base import BaseAdapter, RawCompletion
6
+
7
+
8
+ class OllamaAdapter(BaseAdapter):
9
+ provider_name = "Ollama"
10
+
11
+ @classmethod
12
+ def matches(cls, client: Any) -> bool:
13
+ return type(client).__module__.startswith("ollama")
14
+
15
+ def complete(self, prompt: str, *, model: Optional[str] = None,
16
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
17
+ model = model or self.default_model or "llama3.1"
18
+ messages = []
19
+ if system:
20
+ messages.append({"role": "system", "content": system})
21
+ messages.append({"role": "user", "content": prompt})
22
+
23
+ response = self.client.chat(
24
+ model=model,
25
+ messages=messages,
26
+ format="json",
27
+ **kwargs,
28
+ )
29
+ text = response["message"]["content"] if isinstance(response, dict) else response.message.content
30
+ return RawCompletion(
31
+ text=text,
32
+ model=model,
33
+ provider=self.provider_name,
34
+ raw_response=response,
35
+ )
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from .base import BaseAdapter, RawCompletion
6
+
7
+
8
+ class OpenAIAdapter(BaseAdapter):
9
+ provider_name = "OpenAI"
10
+
11
+ @classmethod
12
+ def matches(cls, client: Any) -> bool:
13
+ return type(client).__module__.startswith("openai")
14
+
15
+ def complete(self, prompt: str, *, model: Optional[str] = None,
16
+ system: Optional[str] = None, **kwargs) -> RawCompletion:
17
+ model = model or self.default_model or "gpt-4o-mini"
18
+ messages = []
19
+ if system:
20
+ messages.append({"role": "system", "content": system})
21
+ messages.append({"role": "user", "content": prompt})
22
+
23
+ response = self.client.chat.completions.create(
24
+ model=model,
25
+ messages=messages,
26
+ response_format={"type": "json_object"},
27
+ **kwargs,
28
+ )
29
+ choice = response.choices[0].message.content
30
+ usage = getattr(response, "usage", None)
31
+ return RawCompletion(
32
+ text=choice or "",
33
+ model=model,
34
+ provider=self.provider_name,
35
+ tokens_input=getattr(usage, "prompt_tokens", None) if usage else None,
36
+ tokens_output=getattr(usage, "completion_tokens", None) if usage else None,
37
+ raw_response=response,
38
+ )
structguard/core.py ADDED
@@ -0,0 +1,153 @@
1
+ """
2
+ Core orchestrator: the `generate()` function.
3
+
4
+ This wires together every module in the pipeline:
5
+ 1. Normalize the schema (schema/engine.py)
6
+ 2. Augment the prompt with schema instructions (prompting below)
7
+ 3. Call the LLM through the resolved adapter (adapters/)
8
+ 4. Parse + repair the raw text into JSON (repair.py)
9
+ 5. Validate against the schema (validator.py)
10
+ 6. On failure: build corrective feedback and retry (retry.py), up to max_retries
11
+ 7. Return the validated object (+ optional ValidationReport)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from typing import Any, Optional, Union
17
+
18
+ from .adapters import resolve_adapter
19
+ from .exceptions import InvalidResponseError, RetryLimitExceeded, SchemaValidationError
20
+ from .logging_utils import (
21
+ configure_debug_logging,
22
+ log_raw_output,
23
+ log_repair,
24
+ log_retry,
25
+ log_success,
26
+ log_validation_error,
27
+ )
28
+ from .repair import repair_json
29
+ from .report import ValidationReport
30
+ from .retry import build_retry_prompt
31
+ from .schema.engine import normalize
32
+ from .validator import validate
33
+
34
+
35
+ class GenerationResult:
36
+ """Wrapper returned when return_report=True: the object plus its report."""
37
+
38
+ def __init__(self, data: Any, report: ValidationReport):
39
+ self.data = data
40
+ self.report = report
41
+
42
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
43
+ return f"GenerationResult(data={self.data!r}, report={self.report})"
44
+
45
+
46
+ def _build_schema_prompt(user_prompt: str, json_schema: dict) -> str:
47
+ """Provider-Independent Prompting: append schema guidance to the developer's prompt."""
48
+ schema_str = json.dumps(json_schema, indent=2)
49
+ return (
50
+ f"{user_prompt}\n\n"
51
+ "---\n"
52
+ "Respond with ONLY a single valid JSON object (no markdown fences, no commentary) "
53
+ "that strictly conforms to this JSON Schema:\n"
54
+ f"{schema_str}"
55
+ )
56
+
57
+
58
+ def generate(
59
+ *,
60
+ llm: Any,
61
+ prompt: str,
62
+ schema: Any,
63
+ model: Optional[str] = None,
64
+ system: Optional[str] = None,
65
+ max_retries: int = 2,
66
+ debug: bool = False,
67
+ return_report: bool = False,
68
+ **provider_kwargs: Any,
69
+ ) -> Union[Any, GenerationResult]:
70
+ """
71
+ Generate a schema-validated object from an LLM.
72
+
73
+ Args:
74
+ llm: an initialized OpenAI / Anthropic / Gemini / Groq / Ollama client.
75
+ prompt: the developer's natural-language instruction.
76
+ schema: a Pydantic BaseModel, dataclass, TypedDict, or JSON Schema dict.
77
+ model: override the provider's default model.
78
+ system: optional system prompt.
79
+ max_retries: number of corrective retries after the first attempt (default 2).
80
+ debug: emit full lifecycle logs via the "structguard" logger.
81
+ return_report: if True, return a GenerationResult(data, report) instead of just data.
82
+ **provider_kwargs: forwarded verbatim to the underlying provider call.
83
+
84
+ Returns:
85
+ A validated instance of `schema` (or dict, for JSON Schema), or a
86
+ GenerationResult wrapping it + a ValidationReport.
87
+
88
+ Raises:
89
+ UnsupportedProviderError, InvalidSchemaError, JsonRepairError,
90
+ SchemaValidationError, RetryLimitExceeded, InvalidResponseError
91
+ """
92
+ configure_debug_logging(debug)
93
+
94
+ normalized_schema = normalize(schema)
95
+ adapter = resolve_adapter(llm, default_model=model)
96
+ report = ValidationReport(provider=adapter.provider_name, model=model or adapter.default_model or "")
97
+
98
+ current_prompt = _build_schema_prompt(prompt, normalized_schema.json_schema())
99
+ last_errors: list[dict] = []
100
+ last_exc: Optional[Exception] = None
101
+
102
+ attempts = max_retries + 1
103
+ for attempt in range(1, attempts + 1):
104
+ if attempt > 1:
105
+ log_retry(attempt - 1, max_retries)
106
+ report.retries += 1
107
+ current_prompt = build_retry_prompt(
108
+ _build_schema_prompt(prompt, normalized_schema.json_schema()), last_errors
109
+ )
110
+
111
+ completion = adapter.complete(current_prompt, model=model, system=system, **provider_kwargs)
112
+ log_raw_output(completion.text)
113
+
114
+ if not completion.text or not completion.text.strip():
115
+ last_exc = InvalidResponseError(
116
+ "LLM returned an empty response.", context={"provider": adapter.provider_name}
117
+ )
118
+ continue
119
+
120
+ try:
121
+ parsed, repaired = repair_json(completion.text)
122
+ log_repair(repaired, json.dumps(parsed) if repaired else None)
123
+ report.repair_applied = report.repair_applied or repaired
124
+ except Exception as exc: # JsonRepairError
125
+ last_exc = exc
126
+ last_errors = [{"field": "<root>", "issue": str(exc), "type": "json_repair_error"}]
127
+ continue
128
+
129
+ report.mark_validation_start()
130
+ try:
131
+ validated = validate(normalized_schema, parsed, raw_output=completion.text)
132
+ except SchemaValidationError as exc:
133
+ report.mark_validation_end()
134
+ log_validation_error(exc.errors)
135
+ last_exc = exc
136
+ last_errors = exc.errors
137
+ continue
138
+ report.mark_validation_end()
139
+
140
+ report.tokens_input = completion.tokens_input
141
+ report.tokens_output = completion.tokens_output
142
+ report.finalize("success")
143
+ log_success(validated)
144
+
145
+ return GenerationResult(validated, report) if return_report else validated
146
+
147
+ report.errors = last_errors
148
+ report.finalize("failed")
149
+ raise RetryLimitExceeded(
150
+ f"Failed to produce a schema-valid response after {attempts} attempt(s).",
151
+ attempts=attempts,
152
+ last_error=last_exc,
153
+ )
@@ -0,0 +1,62 @@
1
+ """
2
+ Structured exception hierarchy for StructGuard.
3
+
4
+ Every exception carries actionable debugging context instead of a bare message,
5
+ so application code can log/handle failures precisely.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Optional
10
+
11
+
12
+ class StructGuardError(Exception):
13
+ """Base class for all StructGuard exceptions."""
14
+
15
+ def __init__(self, message: str, *, context: Optional[dict[str, Any]] = None):
16
+ self.message = message
17
+ self.context = context or {}
18
+ super().__init__(self._format())
19
+
20
+ def _format(self) -> str:
21
+ if not self.context:
22
+ return self.message
23
+ ctx = ", ".join(f"{k}={v!r}" for k, v in self.context.items())
24
+ return f"{self.message} ({ctx})"
25
+
26
+
27
+ class UnsupportedProviderError(StructGuardError):
28
+ """Raised when an LLM client cannot be mapped to a known adapter."""
29
+
30
+
31
+ class SchemaValidationError(StructGuardError):
32
+ """Raised when the final (possibly repaired) output fails schema validation."""
33
+
34
+ def __init__(self, message: str, *, errors: Optional[list[dict[str, Any]]] = None, raw_output: str = ""):
35
+ self.errors = errors or []
36
+ self.raw_output = raw_output
37
+ super().__init__(message, context={"error_count": len(self.errors)})
38
+
39
+
40
+ class JsonRepairError(StructGuardError):
41
+ """Raised when the repair engine cannot turn raw text into valid JSON."""
42
+
43
+ def __init__(self, message: str, *, raw_output: str = ""):
44
+ self.raw_output = raw_output
45
+ super().__init__(message, context={"raw_output_preview": raw_output[:120]})
46
+
47
+
48
+ class RetryLimitExceeded(StructGuardError):
49
+ """Raised when max_retries is exhausted without producing a valid object."""
50
+
51
+ def __init__(self, message: str, *, attempts: int, last_error: Optional[Exception] = None):
52
+ self.attempts = attempts
53
+ self.last_error = last_error
54
+ super().__init__(message, context={"attempts": attempts, "last_error": str(last_error) if last_error else None})
55
+
56
+
57
+ class InvalidResponseError(StructGuardError):
58
+ """Raised when the LLM provider returns an empty or malformed raw response."""
59
+
60
+
61
+ class InvalidSchemaError(StructGuardError):
62
+ """Raised when the schema passed to `generate()` is not a supported type."""
@@ -0,0 +1,47 @@
1
+ """
2
+ Logging & Debug Mode
3
+ =====================
4
+ When `debug=True` is passed to `generate()`, every stage of the lifecycle
5
+ (raw output, repaired output, validation errors, retries, final object,
6
+ latency) is emitted through the standard `logging` module under the
7
+ "structguard" logger, so it plugs into whatever logging setup the host
8
+ application already has.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+ logger = logging.getLogger("structguard")
15
+
16
+
17
+ def configure_debug_logging(enabled: bool) -> None:
18
+ if not enabled:
19
+ return
20
+ if not logger.handlers:
21
+ handler = logging.StreamHandler()
22
+ handler.setFormatter(logging.Formatter("[structguard] %(levelname)s: %(message)s"))
23
+ logger.addHandler(handler)
24
+ logger.setLevel(logging.DEBUG)
25
+
26
+
27
+ def log_raw_output(raw: str) -> None:
28
+ logger.debug("Raw LLM output:\n%s", raw)
29
+
30
+
31
+ def log_repair(applied: bool, repaired: str | None = None) -> None:
32
+ if applied:
33
+ logger.debug("JSON repair applied. Result:\n%s", repaired)
34
+ else:
35
+ logger.debug("No JSON repair needed.")
36
+
37
+
38
+ def log_validation_error(errors: list[dict]) -> None:
39
+ logger.debug("Validation failed with %d error(s): %s", len(errors), errors)
40
+
41
+
42
+ def log_retry(attempt: int, max_retries: int) -> None:
43
+ logger.debug("Retrying generation (%d/%d)...", attempt, max_retries)
44
+
45
+
46
+ def log_success(obj) -> None:
47
+ logger.debug("Validated object produced successfully: %r", obj)
structguard/repair.py ADDED
@@ -0,0 +1,125 @@
1
+ """
2
+ Automatic JSON Repair Engine
3
+ =============================
4
+ LLMs frequently emit JSON that's *almost* valid: trailing commas, markdown
5
+ code fences, single quotes, unescaped newlines, unclosed braces, etc.
6
+ This module tries a sequence of cheap, deterministic fixes before giving up.
7
+
8
+ Design note: we deliberately avoid a heavy grammar-based JSON parser here to
9
+ keep the SDK dependency-free. Each `_fix_*` step is idempotent and safe to
10
+ chain.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from typing import Any
17
+
18
+ from .exceptions import JsonRepairError
19
+
20
+
21
+ def _strip_code_fences(text: str) -> str:
22
+ text = text.strip()
23
+ fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
24
+ return fence.group(1) if fence else text
25
+
26
+
27
+ def _extract_json_span(text: str) -> str:
28
+ """Grab the outermost {...} or [...] span, ignoring leading/trailing prose."""
29
+ start_candidates = [i for i in (text.find("{"), text.find("[")) if i != -1]
30
+ if not start_candidates:
31
+ return text
32
+ start = min(start_candidates)
33
+ end_brace = text.rfind("}")
34
+ end_bracket = text.rfind("]")
35
+ end = max(end_brace, end_bracket)
36
+ if end == -1 or end < start:
37
+ return text[start:]
38
+ return text[start : end + 1]
39
+
40
+
41
+ def _fix_trailing_commas(text: str) -> str:
42
+ return re.sub(r",\s*([\]}])", r"\1", text)
43
+
44
+
45
+ def _fix_single_quotes(text: str) -> str:
46
+ # Only applied as a last-resort fallback (risky if strings contain apostrophes),
47
+ # so it's kept separate and only used in the final repair attempt.
48
+ return re.sub(r"'([^']*)'", r'"\1"', text)
49
+
50
+
51
+ def _fix_unescaped_newlines_in_strings(text: str) -> str:
52
+ # Collapse raw newlines/tabs that appear inside string literals.
53
+ out, in_string, escape = [], False, False
54
+ for ch in text:
55
+ if escape:
56
+ out.append(ch)
57
+ escape = False
58
+ continue
59
+ if ch == "\\":
60
+ out.append(ch)
61
+ escape = True
62
+ continue
63
+ if ch == '"':
64
+ in_string = not in_string
65
+ out.append(ch)
66
+ continue
67
+ if in_string and ch in ("\n", "\t"):
68
+ out.append("\\n" if ch == "\n" else "\\t")
69
+ continue
70
+ out.append(ch)
71
+ return "".join(out)
72
+
73
+
74
+ def _fix_unclosed_braces(text: str) -> str:
75
+ opens = text.count("{") - text.count("}")
76
+ sq_opens = text.count("[") - text.count("]")
77
+ if opens > 0:
78
+ text += "}" * opens
79
+ if sq_opens > 0:
80
+ text += "]" * sq_opens
81
+ return text
82
+
83
+
84
+ REPAIR_STEPS = [
85
+ _strip_code_fences,
86
+ _extract_json_span,
87
+ _fix_unescaped_newlines_in_strings,
88
+ _fix_trailing_commas,
89
+ _fix_unclosed_braces,
90
+ ]
91
+
92
+
93
+ def repair_json(raw_output: str) -> tuple[dict | list, bool]:
94
+ """
95
+ Attempt to parse `raw_output` as JSON, applying repair steps as needed.
96
+
97
+ Returns (parsed_object, repair_was_applied).
98
+ Raises JsonRepairError if no combination of fixes yields valid JSON.
99
+ """
100
+ if not raw_output or not raw_output.strip():
101
+ raise JsonRepairError("Empty response from LLM; nothing to parse.", raw_output=raw_output)
102
+
103
+ # Fast path: already valid JSON.
104
+ try:
105
+ return json.loads(raw_output), False
106
+ except json.JSONDecodeError:
107
+ pass
108
+
109
+ text = raw_output
110
+ for step in REPAIR_STEPS:
111
+ text = step(text)
112
+ try:
113
+ return json.loads(text), True
114
+ except json.JSONDecodeError:
115
+ continue
116
+
117
+ # Last resort: single-quote conversion (riskier, tried last).
118
+ text = _fix_single_quotes(text)
119
+ try:
120
+ return json.loads(text), True
121
+ except json.JSONDecodeError as exc:
122
+ raise JsonRepairError(
123
+ f"Could not repair malformed JSON: {exc}",
124
+ raw_output=raw_output,
125
+ ) from exc
structguard/report.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ Validation Report
3
+ ==================
4
+ Rich metadata attached to every generation for logging/monitoring/debugging.
5
+ Accessible via `result.report` when using `generate(..., return_report=True)`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Optional
12
+
13
+
14
+ @dataclass
15
+ class ValidationReport:
16
+ status: str = "pending" # "success" | "failed"
17
+ provider: str = ""
18
+ model: str = ""
19
+ retries: int = 0
20
+ repair_applied: bool = False
21
+ validation_time_ms: float = 0.0
22
+ total_time_ms: float = 0.0
23
+ tokens_input: Optional[int] = None
24
+ tokens_output: Optional[int] = None
25
+ errors: list[dict[str, Any]] = field(default_factory=list)
26
+
27
+ _start: float = field(default_factory=time.perf_counter, repr=False, compare=False)
28
+ _validation_start: Optional[float] = field(default=None, repr=False, compare=False)
29
+
30
+ def mark_validation_start(self) -> None:
31
+ self._validation_start = time.perf_counter()
32
+
33
+ def mark_validation_end(self) -> None:
34
+ if self._validation_start is not None:
35
+ self.validation_time_ms = round((time.perf_counter() - self._validation_start) * 1000, 3)
36
+
37
+ def finalize(self, status: str) -> "ValidationReport":
38
+ self.status = status
39
+ self.total_time_ms = round((time.perf_counter() - self._start) * 1000, 3)
40
+ return self
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "status": self.status,
45
+ "provider": self.provider,
46
+ "model": self.model,
47
+ "retries": self.retries,
48
+ "repair_applied": self.repair_applied,
49
+ "validation_time_ms": self.validation_time_ms,
50
+ "total_time_ms": self.total_time_ms,
51
+ "tokens_input": self.tokens_input,
52
+ "tokens_output": self.tokens_output,
53
+ "errors": self.errors,
54
+ }
55
+
56
+ def __str__(self) -> str: # pragma: no cover - cosmetic
57
+ icon = "✓" if self.status == "success" else "✗"
58
+ return (
59
+ f"{icon} {self.status.capitalize()} | provider={self.provider} model={self.model} "
60
+ f"retries={self.retries} repair_applied={self.repair_applied} "
61
+ f"validation={self.validation_time_ms}ms total={self.total_time_ms}ms"
62
+ )
structguard/retry.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Intelligent Retry Engine
3
+ ==========================
4
+ On validation failure, builds targeted corrective feedback (exact fields and
5
+ error reasons) and re-prompts the model, instead of retrying blindly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .validator import errors_to_feedback_prompt
10
+
11
+
12
+ def build_retry_prompt(original_prompt: str, errors: list[dict]) -> str:
13
+ """Compose the next attempt's prompt: original instructions + corrective feedback."""
14
+ feedback = errors_to_feedback_prompt(errors)
15
+ return f"{original_prompt}\n\n---\n{feedback}"
@@ -0,0 +1,3 @@
1
+ from .engine import NormalizedSchema, normalize
2
+
3
+ __all__ = ["NormalizedSchema", "normalize"]
@@ -0,0 +1,100 @@
1
+ """
2
+ Schema Engine
3
+ =============
4
+ Normalizes four different schema styles (Pydantic BaseModel, stdlib dataclass,
5
+ TypedDict, raw JSON Schema dict) into one internal representation so the rest
6
+ of the SDK (validator, repair, prompting) doesn't need to care which style the
7
+ developer used.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import json
13
+ import typing
14
+ from typing import Any, Type, Union
15
+
16
+ from pydantic import BaseModel, TypeAdapter, ValidationError
17
+
18
+ from ..exceptions import InvalidSchemaError
19
+
20
+
21
+ class NormalizedSchema:
22
+ """
23
+ Wraps any supported schema type behind a single interface:
24
+ - .json_schema() -> dict, for prompt injection
25
+ - .validate(data) -> instance (or dict for JSON Schema mode)
26
+ - .kind -> "pydantic" | "dataclass" | "typeddict" | "json_schema"
27
+ """
28
+
29
+ def __init__(self, schema: Any):
30
+ self.original = schema
31
+ self.kind, self._adapter, self._target = self._resolve(schema)
32
+
33
+ # ---- resolution -------------------------------------------------
34
+
35
+ @staticmethod
36
+ def _resolve(schema: Any):
37
+ # 1. Pydantic BaseModel subclass
38
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
39
+ return "pydantic", TypeAdapter(schema), schema
40
+
41
+ # 2. stdlib dataclass
42
+ if dataclasses.is_dataclass(schema) and isinstance(schema, type):
43
+ return "dataclass", TypeAdapter(schema), schema
44
+
45
+ # 3. TypedDict (detected via __required_keys__/__annotations__ pattern)
46
+ if isinstance(schema, type) and hasattr(schema, "__annotations__") and hasattr(schema, "__total__"):
47
+ return "typeddict", TypeAdapter(schema), schema
48
+
49
+ # 4. Raw JSON Schema dict
50
+ if isinstance(schema, dict) and ("type" in schema or "properties" in schema):
51
+ return "json_schema", None, schema
52
+
53
+ raise InvalidSchemaError(
54
+ "Unsupported schema type. Use a Pydantic BaseModel, a dataclass, "
55
+ "a TypedDict, or a raw JSON Schema dict.",
56
+ context={"schema_repr": repr(schema)[:200]},
57
+ )
58
+
59
+ # ---- public API ---------------------------------------------------
60
+
61
+ def json_schema(self) -> dict:
62
+ """Return a JSON-Schema dict describing the expected output shape."""
63
+ if self.kind == "json_schema":
64
+ return self._target
65
+ return self._adapter.json_schema()
66
+
67
+ def validate(self, data: dict) -> Any:
68
+ """
69
+ Validate `data` against the schema.
70
+ Returns a Pydantic/dataclass instance for those kinds, or the raw
71
+ dict (unchanged) for json_schema kind since there's no Python class
72
+ to instantiate.
73
+ Raises pydantic.ValidationError on failure (caller wraps it).
74
+ """
75
+ if self.kind == "json_schema":
76
+ # Lightweight structural check via pydantic's generic validator
77
+ TypeAdapter(dict).validate_python(data)
78
+ self._check_json_schema(data, self._target)
79
+ return data
80
+ return self._adapter.validate_python(data)
81
+
82
+ @staticmethod
83
+ def _check_json_schema(data: dict, schema: dict) -> None:
84
+ """Minimal required-field + type check for raw JSON Schema dicts."""
85
+ required = schema.get("required", [])
86
+ missing = [f for f in required if f not in data]
87
+ if missing:
88
+ raise ValidationError.from_exception_data(
89
+ "JSONSchema",
90
+ [{"type": "missing", "loc": (f,), "input": data} for f in missing],
91
+ )
92
+
93
+ def field_names(self) -> list[str]:
94
+ if self.kind == "json_schema":
95
+ return list(self._target.get("properties", {}).keys())
96
+ return list(self.json_schema().get("properties", {}).keys())
97
+
98
+
99
+ def normalize(schema: Any) -> NormalizedSchema:
100
+ return NormalizedSchema(schema)
@@ -0,0 +1,59 @@
1
+ """
2
+ Intelligent Output Validator
3
+ ==============================
4
+ Runs the parsed/repaired JSON object through the schema engine and turns
5
+ pydantic's ValidationError into a compact, LLM-readable error list that the
6
+ retry engine can feed back as corrective feedback.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+
14
+ from .exceptions import SchemaValidationError
15
+ from .schema.engine import NormalizedSchema
16
+
17
+
18
+ def validate(schema: NormalizedSchema, data: dict, raw_output: str = "") -> Any:
19
+ """
20
+ Validate `data` against `schema`.
21
+ Returns the validated instance (or dict for json_schema kind).
22
+ Raises SchemaValidationError with a structured `.errors` list on failure.
23
+ """
24
+ try:
25
+ return schema.validate(data)
26
+ except ValidationError as exc:
27
+ raise SchemaValidationError(
28
+ f"Output failed schema validation with {exc.error_count()} error(s).",
29
+ errors=format_errors(exc),
30
+ raw_output=raw_output,
31
+ ) from exc
32
+
33
+
34
+ def format_errors(exc: ValidationError) -> list[dict[str, Any]]:
35
+ """Convert pydantic errors into a compact, serializable list."""
36
+ formatted = []
37
+ for err in exc.errors():
38
+ formatted.append(
39
+ {
40
+ "field": ".".join(str(p) for p in err["loc"]) or "<root>",
41
+ "issue": err["msg"],
42
+ "type": err["type"],
43
+ }
44
+ )
45
+ return formatted
46
+
47
+
48
+ def errors_to_feedback_prompt(errors: list[dict[str, Any]]) -> str:
49
+ """
50
+ Render validation errors as the corrective feedback message sent back
51
+ to the LLM on retry (see spec section "Intelligent Retry Engine").
52
+ """
53
+ lines = ["Your previous response failed schema validation.", "", "Errors:"]
54
+ for e in errors:
55
+ lines.append(f"- {e['field']}: {e['issue']}")
56
+ lines.append("")
57
+ lines.append("Please regenerate a complete, valid JSON object that fixes every error above. "
58
+ "Return ONLY the JSON object, with no commentary or markdown formatting.")
59
+ return "\n".join(lines)
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: structguard
3
+ Version: 0.1.2
4
+ Summary: Reliable, provider-agnostic structured outputs for production LLM applications.
5
+ Project-URL: Homepage, https://github.com/yourusername/structguard
6
+ Project-URL: Repository, https://github.com/yourusername/structguard
7
+ Project-URL: Issues, https://github.com/yourusername/structguard/issues
8
+ Author-email: Sujan Ghosh <rupubally@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: anthropic,gemini,json,llm,openai,pydantic,structured-output,validation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: pydantic>=2.0
23
+ Provides-Extra: all
24
+ Requires-Dist: anthropic>=0.30; extra == 'all'
25
+ Requires-Dist: google-generativeai>=0.5; extra == 'all'
26
+ Requires-Dist: groq>=0.5; extra == 'all'
27
+ Requires-Dist: openai>=1.0; extra == 'all'
28
+ Provides-Extra: anthropic
29
+ Requires-Dist: anthropic>=0.30; extra == 'anthropic'
30
+ Provides-Extra: dev
31
+ Requires-Dist: build; extra == 'dev'
32
+ Requires-Dist: pytest-cov; extra == 'dev'
33
+ Requires-Dist: pytest>=7.0; extra == 'dev'
34
+ Requires-Dist: twine; extra == 'dev'
35
+ Provides-Extra: gemini
36
+ Requires-Dist: google-generativeai>=0.5; extra == 'gemini'
37
+ Provides-Extra: groq
38
+ Requires-Dist: groq>=0.5; extra == 'groq'
39
+ Provides-Extra: openai
40
+ Requires-Dist: openai>=1.0; extra == 'openai'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # StructGuard
44
+
45
+ **Reliable, provider-agnostic structured outputs for production LLM applications.**
46
+
47
+ StructGuard makes sure every LLM response conforms to a schema you define —
48
+ `Pydantic`, `dataclass`, `TypedDict`, or raw JSON Schema — before it reaches
49
+ your application code. It handles prompting, JSON repair, validation, and
50
+ intelligent retries in one call, across OpenAI, Anthropic, Gemini, Groq, and
51
+ Ollama.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install structguard # core only
57
+ pip install structguard[openai] # + OpenAI SDK
58
+ pip install structguard[anthropic] # + Anthropic SDK
59
+ pip install structguard[all] # every provider SDK
60
+ ```
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ from pydantic import BaseModel
66
+ from openai import OpenAI
67
+ from structguard import generate
68
+
69
+ class Incident(BaseModel):
70
+ summary: str
71
+ priority: str
72
+ confidence: float
73
+
74
+ client = OpenAI()
75
+
76
+ incident = generate(
77
+ llm=client,
78
+ prompt="Summarize this incident: Database connection pool exhausted, causing 500s.",
79
+ schema=Incident,
80
+ )
81
+
82
+ print(incident.summary, incident.priority, incident.confidence)
83
+ ```
84
+
85
+ ## With a full report
86
+
87
+ ```python
88
+ result = generate(llm=client, prompt=prompt, schema=Incident, return_report=True)
89
+ print(result.data)
90
+ print(result.report) # ✓ Success | provider=OpenAI model=gpt-4o-mini retries=0 ...
91
+ ```
92
+
93
+ ## Error handling
94
+
95
+ ```python
96
+ from structguard import RetryLimitExceeded, SchemaValidationError
97
+
98
+ try:
99
+ incident = generate(llm=client, prompt=prompt, schema=Incident, max_retries=2)
100
+ except RetryLimitExceeded as e:
101
+ print(f"Gave up after {e.attempts} attempts: {e.last_error}")
102
+ ```
103
+
104
+ ## Supported schema types
105
+
106
+ - `pydantic.BaseModel`
107
+ - stdlib `@dataclass`
108
+ - `TypedDict`
109
+ - raw JSON Schema `dict`
110
+
111
+ ## Supported providers
112
+
113
+ OpenAI · Anthropic · Google Gemini · Groq · Ollama
114
+
115
+ ## Development
116
+
117
+ ```bash
118
+ git clone https://github.com/sujanrupu/structguard
119
+ cd structguard
120
+ pip install -e ".[dev]"
121
+ pytest
122
+ ```
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,21 @@
1
+ structguard/__init__.py,sha256=wJWPmdamBxEMRR3I3HG1ttEsNuZ8yig5VNPbe4qh1vw,805
2
+ structguard/core.py,sha256=fjtTuQ09GaMRm0wzKnTSARh-x8M8DqKWtRfDcVVHveU,5617
3
+ structguard/exceptions.py,sha256=K8AIKDGCahr3HvNenCrVroRXLfOud5u5lhyMLiLYaqw,2245
4
+ structguard/logging_utils.py,sha256=fIE6IQkhwdUMUq5yq-i0AJ3dhtLCVCl4PJmvuB3NeMI,1437
5
+ structguard/repair.py,sha256=hgarjb3BMzXFgOE7YqWOHENdcPJUfaIC4IrOpUxUDXI,3748
6
+ structguard/report.py,sha256=Um2LcvtkL0loqh6VADoUomBprHIGX7Hx_CSsplXCgoA,2293
7
+ structguard/retry.py,sha256=GIpcBWG1ZgidxUVvKrzrf4rdmkBQKUV-7Kz5Ey9I_wg,557
8
+ structguard/validator.py,sha256=-pp4hrXprWYkgQRVShw1ljxWsQhzrzwWccnKYvM0dUo,2076
9
+ structguard/adapters/__init__.py,sha256=A62NWPmHDvjxj2W5dTCFg6bPpRXA8XFSlM1yiHWjmZw,1207
10
+ structguard/adapters/anthropic_adapter.py,sha256=DIYBEsoJgppWifi1cbA0Wrukc4YnJkvXz0ZaT-iJHTs,1324
11
+ structguard/adapters/base.py,sha256=YiZOJ8q2_hcm_Wvi8ejP-rvJ0nldPIgTDKhf5nbyrRY,1242
12
+ structguard/adapters/gemini_adapter.py,sha256=QwKEbFFp2UZNr0l8Z10mQANoNtcFH2S9-5F9FQlDX4o,1464
13
+ structguard/adapters/groq_adapter.py,sha256=6KkDXSdOj6ccXLl8W7cDre-gs47jXyJrtKhzIBKkqcQ,1324
14
+ structguard/adapters/ollama_adapter.py,sha256=R9hUnqBO8BXOD_SLfdLyDrCRCPScLaS4RIB44GgSxwM,1095
15
+ structguard/adapters/openai_adapter.py,sha256=0dfWUxfc3iWNHtpO9m6ZJI0QpDhrkMHABEEyBusM9r8,1318
16
+ structguard/schema/__init__.py,sha256=Gp2dslBmGEvvMPZ3oLX5fYwvnv-I7U2ANWrB7aPdRCw,93
17
+ structguard/schema/engine.py,sha256=MP74RlrolypJwjusf2urQ6fgyLPkQcC7nN2Cy8LMWkE,3809
18
+ structguard-0.1.2.dist-info/METADATA,sha256=LigNWqgXArflmPrn3X_lgUPVG7bOvRaOGtgBIiK8z6U,3694
19
+ structguard-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
20
+ structguard-0.1.2.dist-info/licenses/LICENSE,sha256=kmBbnNtygbUFZj4Om0JztNUr5QbPNgDERhWR6qitAnE,1068
21
+ structguard-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sujan Ghosh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.