structured-llm 0.1.0__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,22 @@
1
+ from .client import StructuredClient
2
+ from .errors import (
3
+ ProviderError,
4
+ StructuredLLMError,
5
+ StructuredParseError,
6
+ StructuredValidationError,
7
+ UnsupportedSchemaError,
8
+ )
9
+ from .parser import parse_structured_text
10
+ from .schema import build_schema_spec
11
+
12
+ __all__ = [
13
+ "ProviderError",
14
+ "StructuredClient",
15
+ "StructuredLLMError",
16
+ "StructuredParseError",
17
+ "StructuredValidationError",
18
+ "UnsupportedSchemaError",
19
+ "build_schema_spec",
20
+ "parse_structured_text",
21
+ ]
22
+
@@ -0,0 +1,459 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import os
6
+ import sys
7
+ from typing import Any, Literal, TypeVar, overload
8
+
9
+ from .errors import ProviderError, StructuredLLMError, StructuredValidationError
10
+ from .parser import parse_structured_text
11
+ from .schema import SchemaSpec, build_schema_spec
12
+
13
+
14
+ Mode = Literal["auto", "native", "prompt"]
15
+ Endpoint = Literal["chat", "responses"]
16
+ T = TypeVar("T")
17
+
18
+
19
+ class StructuredClient:
20
+ def __init__(
21
+ self,
22
+ *,
23
+ model: str,
24
+ api_key: str | None = None,
25
+ base_url: str | None = None,
26
+ mode: Mode = "prompt",
27
+ endpoint: Endpoint = "chat",
28
+ max_retries: int = 1,
29
+ debug: bool = False,
30
+ openai_client: Any | None = None,
31
+ async_openai_client: Any | None = None,
32
+ ) -> None:
33
+ if mode not in {"auto", "native", "prompt"}:
34
+ raise ValueError("mode must be 'auto', 'native', or 'prompt'")
35
+ if endpoint not in {"chat", "responses"}:
36
+ raise ValueError("endpoint must be 'chat' or 'responses'")
37
+ if max_retries < 0:
38
+ raise ValueError("max_retries must be >= 0")
39
+
40
+ self.model = model
41
+ self.api_key = _value_or_env(api_key, "OPENAI_API_KEY")
42
+ self.base_url = _value_or_env(base_url, "OPENAI_BASE_URL")
43
+ self.mode = mode
44
+ self.endpoint = endpoint
45
+ self.max_retries = max_retries
46
+ self.debug = debug
47
+ self._client = openai_client
48
+ self._async_client = async_openai_client
49
+
50
+ @overload
51
+ def run(
52
+ self,
53
+ prompt: str,
54
+ schema: type[T],
55
+ *,
56
+ system: str | None = None,
57
+ **model_options: Any,
58
+ ) -> T: ...
59
+
60
+ @overload
61
+ def run(
62
+ self,
63
+ prompt: str,
64
+ schema: Any,
65
+ *,
66
+ system: str | None = None,
67
+ **model_options: Any,
68
+ ) -> Any: ...
69
+
70
+ def run(
71
+ self,
72
+ prompt: str,
73
+ schema: Any,
74
+ *,
75
+ system: str | None = None,
76
+ **model_options: Any,
77
+ ) -> Any:
78
+ spec = build_schema_spec(schema)
79
+ raw_text = self._complete(prompt, spec, system=system, **model_options)
80
+ return self._parse_with_retry(raw_text, spec, prompt=prompt, system=system, model_options=model_options)
81
+
82
+ @overload
83
+ async def arun(
84
+ self,
85
+ prompt: str,
86
+ schema: type[T],
87
+ *,
88
+ system: str | None = None,
89
+ **model_options: Any,
90
+ ) -> T: ...
91
+
92
+ @overload
93
+ async def arun(
94
+ self,
95
+ prompt: str,
96
+ schema: Any,
97
+ *,
98
+ system: str | None = None,
99
+ **model_options: Any,
100
+ ) -> Any: ...
101
+
102
+ async def arun(
103
+ self,
104
+ prompt: str,
105
+ schema: Any,
106
+ *,
107
+ system: str | None = None,
108
+ **model_options: Any,
109
+ ) -> Any:
110
+ spec = build_schema_spec(schema)
111
+ raw_text = await self._acomplete(prompt, spec, system=system, **model_options)
112
+ return await self._aparse_with_retry(
113
+ raw_text,
114
+ spec,
115
+ prompt=prompt,
116
+ system=system,
117
+ model_options=model_options,
118
+ )
119
+
120
+ @overload
121
+ def parse(self, text: str, schema: type[T], *, repair: bool = True) -> T: ...
122
+
123
+ @overload
124
+ def parse(self, text: str, schema: Any, *, repair: bool = True) -> Any: ...
125
+
126
+ def parse(self, text: str, schema: Any, *, repair: bool = True) -> Any:
127
+ return parse_structured_text(text, schema, repair=repair)
128
+
129
+ def _complete(
130
+ self,
131
+ prompt: str,
132
+ spec: SchemaSpec,
133
+ *,
134
+ system: str | None,
135
+ **model_options: Any,
136
+ ) -> str:
137
+ if self.mode == "prompt":
138
+ return self._call_prompt(prompt, spec, system=system, **model_options)
139
+ if self.mode == "native":
140
+ return self._call_native(prompt, spec, system=system, **model_options)
141
+ try:
142
+ return self._call_native(prompt, spec, system=system, **model_options)
143
+ except ProviderError as exc:
144
+ if not _looks_like_unsupported_native(exc):
145
+ raise
146
+ return self._call_prompt(prompt, spec, system=system, **model_options)
147
+
148
+ async def _acomplete(
149
+ self,
150
+ prompt: str,
151
+ spec: SchemaSpec,
152
+ *,
153
+ system: str | None,
154
+ **model_options: Any,
155
+ ) -> str:
156
+ if self.mode == "prompt":
157
+ return await self._acall_prompt(prompt, spec, system=system, **model_options)
158
+ if self.mode == "native":
159
+ return await self._acall_native(prompt, spec, system=system, **model_options)
160
+ try:
161
+ return await self._acall_native(prompt, spec, system=system, **model_options)
162
+ except ProviderError as exc:
163
+ if not _looks_like_unsupported_native(exc):
164
+ raise
165
+ return await self._acall_prompt(prompt, spec, system=system, **model_options)
166
+
167
+ def _parse_with_retry(
168
+ self,
169
+ raw_text: str,
170
+ spec: SchemaSpec,
171
+ *,
172
+ prompt: str,
173
+ system: str | None,
174
+ model_options: dict[str, Any],
175
+ ) -> Any:
176
+ try:
177
+ return parse_structured_text(raw_text, spec)
178
+ except StructuredValidationError as exc:
179
+ if self.max_retries <= 0:
180
+ raise
181
+ retry_prompt = _retry_prompt(prompt, spec, exc)
182
+ retry_text = self._call_prompt(retry_prompt, spec, system=system, **model_options)
183
+ return parse_structured_text(retry_text, spec)
184
+
185
+ async def _aparse_with_retry(
186
+ self,
187
+ raw_text: str,
188
+ spec: SchemaSpec,
189
+ *,
190
+ prompt: str,
191
+ system: str | None,
192
+ model_options: dict[str, Any],
193
+ ) -> Any:
194
+ try:
195
+ return parse_structured_text(raw_text, spec)
196
+ except StructuredValidationError as exc:
197
+ if self.max_retries <= 0:
198
+ raise
199
+ retry_prompt = _retry_prompt(prompt, spec, exc)
200
+ retry_text = await self._acall_prompt(retry_prompt, spec, system=system, **model_options)
201
+ return parse_structured_text(retry_text, spec)
202
+
203
+ def _call_native(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
204
+ client = self._sync_client()
205
+ try:
206
+ response = self._invoke_sync(client, prompt, spec, system=system, native=True, **model_options)
207
+ raw_text = _extract_response_text(response)
208
+ self._debug_raw_output(raw_text)
209
+ return raw_text
210
+ except StructuredLLMError:
211
+ raise
212
+ except Exception as exc:
213
+ raise ProviderError("Provider native structured output call failed", cause=exc) from exc
214
+
215
+ def _call_prompt(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
216
+ client = self._sync_client()
217
+ prompt = _prompt_with_schema(prompt, spec)
218
+ try:
219
+ response = self._invoke_sync(client, prompt, spec, system=system, native=False, **model_options)
220
+ raw_text = _extract_response_text(response)
221
+ self._debug_raw_output(raw_text)
222
+ return raw_text
223
+ except StructuredLLMError:
224
+ raise
225
+ except Exception as exc:
226
+ raise ProviderError("Provider prompt structured output call failed", cause=exc) from exc
227
+
228
+ async def _acall_native(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
229
+ client = self._async_openai_client()
230
+ try:
231
+ response = await self._invoke_async(client, prompt, spec, system=system, native=True, **model_options)
232
+ raw_text = _extract_response_text(response)
233
+ self._debug_raw_output(raw_text)
234
+ return raw_text
235
+ except StructuredLLMError:
236
+ raise
237
+ except Exception as exc:
238
+ raise ProviderError("Provider native structured output call failed", cause=exc) from exc
239
+
240
+ async def _acall_prompt(self, prompt: str, spec: SchemaSpec, *, system: str | None, **model_options: Any) -> str:
241
+ client = self._async_openai_client()
242
+ prompt = _prompt_with_schema(prompt, spec)
243
+ try:
244
+ response = await self._invoke_async(client, prompt, spec, system=system, native=False, **model_options)
245
+ raw_text = _extract_response_text(response)
246
+ self._debug_raw_output(raw_text)
247
+ return raw_text
248
+ except StructuredLLMError:
249
+ raise
250
+ except Exception as exc:
251
+ raise ProviderError("Provider prompt structured output call failed", cause=exc) from exc
252
+
253
+ def _invoke_sync(
254
+ self,
255
+ client: Any,
256
+ prompt: str,
257
+ spec: SchemaSpec,
258
+ *,
259
+ system: str | None,
260
+ native: bool,
261
+ **model_options: Any,
262
+ ) -> Any:
263
+ if self.endpoint == "responses":
264
+ kwargs = self._responses_kwargs(prompt, spec, system=system, native=native, **model_options)
265
+ self._debug_request("responses.create", kwargs)
266
+ return client.responses.create(**kwargs)
267
+ kwargs = self._chat_kwargs(prompt, spec, system=system, native=native, **model_options)
268
+ self._debug_request("chat.completions.create", kwargs)
269
+ return client.chat.completions.create(**kwargs)
270
+
271
+ async def _invoke_async(
272
+ self,
273
+ client: Any,
274
+ prompt: str,
275
+ spec: SchemaSpec,
276
+ *,
277
+ system: str | None,
278
+ native: bool,
279
+ **model_options: Any,
280
+ ) -> Any:
281
+ if self.endpoint == "responses":
282
+ kwargs = self._responses_kwargs(prompt, spec, system=system, native=native, **model_options)
283
+ self._debug_request("responses.create", kwargs)
284
+ return await _maybe_await(client.responses.create(**kwargs))
285
+ kwargs = self._chat_kwargs(prompt, spec, system=system, native=native, **model_options)
286
+ self._debug_request("chat.completions.create", kwargs)
287
+ return await _maybe_await(client.chat.completions.create(**kwargs))
288
+
289
+ def _chat_kwargs(
290
+ self,
291
+ prompt: str,
292
+ spec: SchemaSpec,
293
+ *,
294
+ system: str | None,
295
+ native: bool,
296
+ **model_options: Any,
297
+ ) -> dict[str, Any]:
298
+ messages = []
299
+ if system:
300
+ messages.append({"role": "system", "content": system})
301
+ messages.append({"role": "user", "content": prompt})
302
+ kwargs = {"model": self.model, "messages": messages, **model_options}
303
+ if native:
304
+ kwargs["response_format"] = _chat_response_format(spec)
305
+ return kwargs
306
+
307
+ def _responses_kwargs(
308
+ self,
309
+ prompt: str,
310
+ spec: SchemaSpec,
311
+ *,
312
+ system: str | None,
313
+ native: bool,
314
+ **model_options: Any,
315
+ ) -> dict[str, Any]:
316
+ input_text = prompt if system is None else f"{system}\n\n{prompt}"
317
+ kwargs = {"model": self.model, "input": input_text, **model_options}
318
+ if native:
319
+ kwargs["text"] = {"format": _responses_text_format(spec)}
320
+ return kwargs
321
+
322
+ def _sync_client(self) -> Any:
323
+ if self._client is None:
324
+ try:
325
+ from openai import OpenAI
326
+ except ImportError as exc: # pragma: no cover - dependency is declared
327
+ raise ProviderError("openai package is required for provider calls", cause=exc) from exc
328
+ self._client = OpenAI(api_key=self.api_key, base_url=self.base_url)
329
+ return self._client
330
+
331
+ def _async_openai_client(self) -> Any:
332
+ if self._async_client is None:
333
+ try:
334
+ from openai import AsyncOpenAI
335
+ except ImportError as exc: # pragma: no cover - dependency is declared
336
+ raise ProviderError("openai package is required for provider calls", cause=exc) from exc
337
+ self._async_client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url)
338
+ return self._async_client
339
+
340
+ def _debug_request(self, endpoint: str, kwargs: dict[str, Any]) -> None:
341
+ if not self.debug:
342
+ return
343
+ print(f"[structured-llm debug] request {endpoint}", file=sys.stderr)
344
+ print(json.dumps(kwargs, ensure_ascii=False, indent=2, default=repr), file=sys.stderr)
345
+
346
+ def _debug_raw_output(self, raw_text: str) -> None:
347
+ if not self.debug:
348
+ return
349
+ print("[structured-llm debug] raw output", file=sys.stderr)
350
+ print(raw_text, file=sys.stderr)
351
+
352
+
353
+ def _chat_response_format(spec: SchemaSpec) -> dict[str, Any]:
354
+ return {
355
+ "type": "json_schema",
356
+ "json_schema": {
357
+ "name": spec.name,
358
+ "strict": True,
359
+ "schema": spec.native_json_schema,
360
+ },
361
+ }
362
+
363
+
364
+ def _responses_text_format(spec: SchemaSpec) -> dict[str, Any]:
365
+ return {
366
+ "type": "json_schema",
367
+ "name": spec.name,
368
+ "strict": True,
369
+ "schema": spec.native_json_schema,
370
+ }
371
+
372
+
373
+ def _prompt_with_schema(prompt: str, spec: SchemaSpec) -> str:
374
+ return (
375
+ f"{prompt.rstrip()}\n\n"
376
+ "Return a JSON value that matches this output format:\n"
377
+ f"{spec.prompt_schema}\n\n"
378
+ "Return only the JSON value. Do not include markdown fences, explanations, or extra text."
379
+ )
380
+
381
+
382
+ def _retry_prompt(prompt: str, spec: SchemaSpec, exc: StructuredValidationError) -> str:
383
+ return (
384
+ f"{prompt.rstrip()}\n\n"
385
+ "The previous response did not match the required JSON schema.\n"
386
+ f"Validation error:\n{exc.validation_error}\n\n"
387
+ "Return corrected JSON only, matching this schema:\n"
388
+ f"{spec.prompt_schema}"
389
+ )
390
+
391
+
392
+ def _looks_like_unsupported_native(exc: ProviderError) -> bool:
393
+ text = f"{exc} {exc.cause}".lower()
394
+ markers = [
395
+ "response_format",
396
+ "json_schema",
397
+ "unsupported",
398
+ "not supported",
399
+ "unknown parameter",
400
+ "invalid parameter",
401
+ "extra fields not permitted",
402
+ ]
403
+ return any(marker in text for marker in markers)
404
+
405
+
406
+ def _value_or_env(value: str | None, env_name: str) -> str | None:
407
+ if value is not None:
408
+ return value
409
+ return os.environ.get(env_name)
410
+
411
+
412
+ def _extract_response_text(response: Any) -> str:
413
+ if isinstance(response, str):
414
+ return response
415
+ output_text = _get_attr_or_item(response, "output_text")
416
+ if isinstance(output_text, str):
417
+ return output_text
418
+
419
+ choices = _get_attr_or_item(response, "choices")
420
+ if choices:
421
+ message = _get_attr_or_item(choices[0], "message")
422
+ content = _get_attr_or_item(message, "content")
423
+ if isinstance(content, str):
424
+ return content
425
+ if isinstance(content, list):
426
+ return "".join(
427
+ part.get("text", "") if isinstance(part, dict) else str(part)
428
+ for part in content
429
+ )
430
+
431
+ output = _get_attr_or_item(response, "output")
432
+ if output:
433
+ texts: list[str] = []
434
+ for item in output:
435
+ content = _get_attr_or_item(item, "content")
436
+ if not content:
437
+ continue
438
+ for part in content:
439
+ text = _get_attr_or_item(part, "text")
440
+ if isinstance(text, str):
441
+ texts.append(text)
442
+ if texts:
443
+ return "".join(texts)
444
+
445
+ raise ProviderError("Provider response did not contain text output")
446
+
447
+
448
+ def _get_attr_or_item(obj: Any, key: str) -> Any:
449
+ if obj is None:
450
+ return None
451
+ if isinstance(obj, dict):
452
+ return obj.get(key)
453
+ return getattr(obj, key, None)
454
+
455
+
456
+ async def _maybe_await(value: Any) -> Any:
457
+ if inspect.isawaitable(value):
458
+ return await value
459
+ return value
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class StructuredLLMError(Exception):
7
+ """Base exception for structured_llm."""
8
+
9
+
10
+ class UnsupportedSchemaError(StructuredLLMError):
11
+ """Raised when a Python type cannot be converted into a usable schema."""
12
+
13
+
14
+ class ProviderError(StructuredLLMError):
15
+ """Raised when the model provider call fails."""
16
+
17
+ def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
18
+ super().__init__(message)
19
+ self.cause = cause
20
+
21
+
22
+ class StructuredParseError(StructuredLLMError):
23
+ """Raised when no JSON value can be extracted from model output."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ raw_text: str,
30
+ schema_name: str,
31
+ cause: BaseException | None = None,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.raw_text = raw_text
35
+ self.schema_name = schema_name
36
+ self.cause = cause
37
+
38
+
39
+ class StructuredValidationError(StructuredLLMError):
40
+ """Raised when extracted JSON does not validate against the target schema."""
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ *,
46
+ raw_text: str,
47
+ schema_name: str,
48
+ validation_error: Any,
49
+ ) -> None:
50
+ super().__init__(message)
51
+ self.raw_text = raw_text
52
+ self.schema_name = schema_name
53
+ self.validation_error = validation_error
54
+
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, TypeVar, overload
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from .errors import StructuredParseError, StructuredValidationError
10
+ from .schema import SchemaSpec, build_schema_spec
11
+
12
+
13
+ _FENCED_JSON_RE = re.compile(r"```(?:json|JSON)?\s*(.*?)```", re.DOTALL)
14
+ _TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
15
+ T = TypeVar("T")
16
+
17
+
18
+ @overload
19
+ def parse_structured_text(
20
+ text: str,
21
+ schema: type[T],
22
+ *,
23
+ repair: bool = True,
24
+ schema_name: str | None = None,
25
+ ) -> T: ...
26
+
27
+
28
+ @overload
29
+ def parse_structured_text(
30
+ text: str,
31
+ schema: Any,
32
+ *,
33
+ repair: bool = True,
34
+ schema_name: str | None = None,
35
+ ) -> Any: ...
36
+
37
+
38
+ def parse_structured_text(
39
+ text: str,
40
+ schema: Any,
41
+ *,
42
+ repair: bool = True,
43
+ schema_name: str | None = None,
44
+ ) -> Any:
45
+ spec = schema if isinstance(schema, SchemaSpec) else build_schema_spec(schema, name=schema_name)
46
+ value = _extract_json_value(text, repair=repair, schema_name=spec.name)
47
+ try:
48
+ return spec.adapter.validate_python(value)
49
+ except ValidationError as exc:
50
+ raise StructuredValidationError(
51
+ f"Response did not match schema {spec.name}",
52
+ raw_text=text,
53
+ schema_name=spec.name,
54
+ validation_error=exc,
55
+ ) from exc
56
+
57
+
58
+ def _extract_json_value(text: str, *, repair: bool, schema_name: str) -> Any:
59
+ candidates = _candidate_json_strings(text)
60
+ last_error: BaseException | None = None
61
+
62
+ for candidate in candidates:
63
+ for variant in _repair_variants(candidate) if repair else (candidate,):
64
+ try:
65
+ return json.loads(variant)
66
+ except json.JSONDecodeError as exc:
67
+ last_error = exc
68
+
69
+ raise StructuredParseError(
70
+ f"No JSON value could be extracted for schema {schema_name}",
71
+ raw_text=text,
72
+ schema_name=schema_name,
73
+ cause=last_error,
74
+ )
75
+
76
+
77
+ def _candidate_json_strings(text: str) -> list[str]:
78
+ stripped = text.strip()
79
+ candidates: list[str] = []
80
+ if stripped:
81
+ candidates.append(stripped)
82
+
83
+ for match in _FENCED_JSON_RE.finditer(text):
84
+ block = match.group(1).strip()
85
+ if block:
86
+ candidates.append(block)
87
+
88
+ extracted = _first_balanced_json(text)
89
+ if extracted and extracted not in candidates:
90
+ candidates.append(extracted)
91
+
92
+ return candidates
93
+
94
+
95
+ def _repair_variants(candidate: str) -> tuple[str, ...]:
96
+ stripped = candidate.strip()
97
+ no_trailing_commas = _TRAILING_COMMA_RE.sub(r"\1", stripped)
98
+ variants = [stripped]
99
+ if no_trailing_commas != stripped:
100
+ variants.append(no_trailing_commas)
101
+ return tuple(dict.fromkeys(variants))
102
+
103
+
104
+ def _first_balanced_json(text: str) -> str | None:
105
+ for start, char in enumerate(text):
106
+ if char not in "{[":
107
+ continue
108
+ result = _balanced_from(text, start)
109
+ if result is not None:
110
+ return result
111
+ return None
112
+
113
+
114
+ def _balanced_from(text: str, start: int) -> str | None:
115
+ opener = text[start]
116
+ closer = "}" if opener == "{" else "]"
117
+ stack = [closer]
118
+ in_string = False
119
+ escape = False
120
+
121
+ for idx in range(start + 1, len(text)):
122
+ char = text[idx]
123
+ if in_string:
124
+ if escape:
125
+ escape = False
126
+ elif char == "\\":
127
+ escape = True
128
+ elif char == '"':
129
+ in_string = False
130
+ continue
131
+
132
+ if char == '"':
133
+ in_string = True
134
+ elif char in "{[":
135
+ stack.append("}" if char == "{" else "]")
136
+ elif char in "}]":
137
+ if not stack or char != stack[-1]:
138
+ return None
139
+ stack.pop()
140
+ if not stack:
141
+ return text[start : idx + 1]
142
+
143
+ return None
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, TypeAdapter
9
+
10
+ from .errors import UnsupportedSchemaError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SchemaSpec:
15
+ name: str
16
+ adapter: TypeAdapter[Any]
17
+ json_schema: dict[str, Any]
18
+ native_json_schema: dict[str, Any]
19
+ prompt_schema: str
20
+
21
+
22
+ def build_schema_spec(schema: Any, *, name: str | None = None) -> SchemaSpec:
23
+ try:
24
+ adapter = TypeAdapter(schema)
25
+ json_schema = adapter.json_schema()
26
+ except Exception as exc: # pragma: no cover - exact pydantic errors vary
27
+ raise UnsupportedSchemaError(f"Unsupported schema: {schema!r}") from exc
28
+
29
+ schema_name = _schema_name(schema, name)
30
+ prompt_schema = render_prompt_schema(json_schema)
31
+ return SchemaSpec(
32
+ name=schema_name,
33
+ adapter=adapter,
34
+ json_schema=json_schema,
35
+ native_json_schema=_to_strict_json_schema(json_schema),
36
+ prompt_schema=prompt_schema,
37
+ )
38
+
39
+
40
+ def render_prompt_schema(json_schema: dict[str, Any]) -> str:
41
+ defs = json_schema.get("$defs", {})
42
+ return _render_type(json_schema, defs, set())
43
+
44
+
45
+ def _schema_name(schema: Any, explicit: str | None) -> str:
46
+ if explicit:
47
+ raw = explicit
48
+ elif isinstance(schema, type):
49
+ raw = schema.__name__
50
+ else:
51
+ raw = getattr(schema, "__name__", "structured_output")
52
+ name = re.sub(r"[^a-zA-Z0-9_-]+", "_", raw).strip("_") or "structured_output"
53
+ return name[:64]
54
+
55
+
56
+ def _render_type(schema: dict[str, Any], defs: dict[str, Any], seen: set[str]) -> str:
57
+ if "$ref" in schema:
58
+ ref_name = schema["$ref"].rsplit("/", 1)[-1]
59
+ if ref_name in seen:
60
+ return ref_name
61
+ target = defs.get(ref_name)
62
+ if not isinstance(target, dict):
63
+ return ref_name
64
+ return _render_type(target, defs, {*seen, ref_name})
65
+
66
+ if "const" in schema:
67
+ return repr(schema["const"]).replace("'", '"')
68
+
69
+ if "enum" in schema:
70
+ return " or ".join(repr(item).replace("'", '"') for item in schema["enum"])
71
+
72
+ if "anyOf" in schema:
73
+ return " or ".join(_render_type(item, defs, seen) for item in schema["anyOf"])
74
+
75
+ if "oneOf" in schema:
76
+ return " or ".join(_render_type(item, defs, seen) for item in schema["oneOf"])
77
+
78
+ schema_type = schema.get("type")
79
+ if isinstance(schema_type, list):
80
+ return " or ".join(_render_type({**schema, "type": item}, defs, seen) for item in schema_type)
81
+
82
+ if schema_type == "object" or "properties" in schema:
83
+ properties = schema.get("properties", {})
84
+ required = set(schema.get("required", []))
85
+ if not properties:
86
+ return "object"
87
+ lines = ["{"]
88
+ for key, value in properties.items():
89
+ metadata = []
90
+ if key not in required:
91
+ metadata.append("optional")
92
+ description = _field_description(value)
93
+ if description:
94
+ metadata.append(description)
95
+ suffix = f" ({'; '.join(metadata)})" if metadata else ""
96
+ rendered = _render_type(value, defs, seen)
97
+ lines.append(f" {key}: {rendered}{suffix},")
98
+ lines.append("}")
99
+ return "\n".join(lines)
100
+
101
+ if schema_type == "array":
102
+ item_schema = schema.get("items", {})
103
+ return f"[{_render_type(item_schema, defs, seen)}]"
104
+
105
+ if schema_type == "string":
106
+ return "string"
107
+ if schema_type == "integer":
108
+ return "int"
109
+ if schema_type == "number":
110
+ return "float"
111
+ if schema_type == "boolean":
112
+ return "bool"
113
+ if schema_type == "null":
114
+ return "null"
115
+
116
+ return "any"
117
+
118
+
119
+ def _field_description(schema: dict[str, Any]) -> str | None:
120
+ description = schema.get("description")
121
+ if not isinstance(description, str):
122
+ return None
123
+ return " ".join(description.split())
124
+
125
+
126
+ def _to_strict_json_schema(schema: Any) -> Any:
127
+ if isinstance(schema, list):
128
+ return [_to_strict_json_schema(item) for item in schema]
129
+ if not isinstance(schema, dict):
130
+ return schema
131
+
132
+ strict = {key: _to_strict_json_schema(value) for key, value in schema.items()}
133
+
134
+ if "$defs" in strict and isinstance(strict["$defs"], dict):
135
+ strict["$defs"] = {
136
+ key: _to_strict_json_schema(value)
137
+ for key, value in strict["$defs"].items()
138
+ }
139
+
140
+ properties = strict.get("properties")
141
+ if isinstance(properties, dict):
142
+ strict["additionalProperties"] = False
143
+ strict["required"] = list(properties.keys())
144
+
145
+ return strict
146
+
147
+
148
+ def is_pydantic_model(schema: Any) -> bool:
149
+ return isinstance(schema, type) and issubclass(schema, BaseModel)
150
+
151
+
152
+ def is_enum(schema: Any) -> bool:
153
+ return isinstance(schema, type) and issubclass(schema, Enum)
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: structured-llm
3
+ Version: 0.1.0
4
+ Summary: Lightweight structured output runtime for Python LLM calls.
5
+ Author: structured-llm contributors
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: openai>=1.0.0
9
+ Requires-Dist: pydantic>=2.0.0
10
+ Requires-Dist: python-dotenv>=1.2.2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # structured-llm
14
+
15
+ 一个轻量的 Python 结构化输出运行时。
16
+
17
+ 它直接使用 Pydantic 类型作为 schema,不需要 `.baml` 文件、CLI、代码生成,也不需要额外的运行时编译器。默认行为是 BAML 风格的「output format prompt + 本地 JSON 提取/轻量修复 + Pydantic 校验」,因此不依赖特定供应商是否支持 `response_format`。
18
+
19
+ ## 安装依赖
20
+
21
+ 普通运行依赖:
22
+
23
+ ```bash
24
+ uv sync --no-config --default-index https://pypi.org/simple
25
+ ```
26
+
27
+ 开发依赖安装在 `dev` dependency group 中,包括 `pytest`、`pytest-asyncio`、`ruff`、`mypy`:
28
+
29
+ ```bash
30
+ uv sync --group dev --no-config --default-index https://pypi.org/simple
31
+ ```
32
+
33
+ 如果需要新增开发工具依赖:
34
+
35
+ ```bash
36
+ uv add <package> --group dev --no-config --default-index https://pypi.org/simple
37
+ ```
38
+
39
+ ## 使用示例
40
+
41
+ ```python
42
+ from pydantic import BaseModel, Field
43
+ from structured_llm import StructuredClient
44
+
45
+
46
+ class Receipt(BaseModel):
47
+ merchant: str = Field(description="商户或店铺名称")
48
+ total: float = Field(description="收据最终支付总金额")
49
+
50
+
51
+ client = StructuredClient(model="gpt-4o-mini", debug=True)
52
+ receipt = client.run("Extract the receipt: Coffee $4.50", Receipt)
53
+
54
+ print(receipt.merchant)
55
+ print(receipt.total)
56
+ ```
57
+
58
+ 默认 OpenAI-compatible provider 会从 `OPENAI_API_KEY` 和 `OPENAI_BASE_URL` 读取配置;如果代码里显式传入 `api_key` 或 `base_url`,会优先使用显式参数。`examples/receipt_extraction.py` 会通过 `python-dotenv` 自动加载本地 `.env`。
59
+
60
+ `Field(description=...)` 会渲染到默认 output format prompt。`debug=True` 会把传给 OpenAI-compatible SDK 的 request payload 和模型解析前的原始输出打印到 stderr。默认不会发送 `response_format`;只有显式设置 `mode="native"` 或 `mode="auto"` 时才会尝试 provider-native structured output。
61
+
62
+ 只解析已有的 LLM 文本输出:
63
+
64
+ ```python
65
+ raw = """
66
+ ```json
67
+ {"merchant": "Coffee Shop", "total": 4.5}
68
+ ```
69
+ """
70
+
71
+ receipt = client.parse(raw, Receipt)
72
+ ```
73
+
74
+ ## 开发命令
75
+
76
+ 运行测试:
77
+
78
+ ```bash
79
+ PYTHONDONTWRITEBYTECODE=1 uv run --no-config --default-index https://pypi.org/simple --group dev python -m pytest -p no:cacheprovider
80
+ ```
81
+
82
+ 运行 Ruff:
83
+
84
+ ```bash
85
+ uv run --no-config --default-index https://pypi.org/simple --group dev ruff check .
86
+ ```
87
+
88
+ 运行 mypy:
89
+
90
+ ```bash
91
+ uv run --no-config --default-index https://pypi.org/simple --group dev mypy structured_llm
92
+ ```
93
+
94
+ ## 当前范围
95
+
96
+ - 支持同步调用:`StructuredClient.run(...)`
97
+ - 支持异步调用:`StructuredClient.arun(...)`
98
+ - 支持本地解析:`StructuredClient.parse(...)`
99
+ - 支持 Pydantic `BaseModel`、`list[...]`、`dict[...]`、`Literal`、`Enum` 等 `TypeAdapter` 可处理的类型
100
+ - 暂不支持流式 partial object、多 provider 内置适配、BAML DSL/codegen
@@ -0,0 +1,8 @@
1
+ structured_llm/__init__.py,sha256=5PF_0CFfAlyKzwBxmYrUw5_A52yXfa7sIzPEzOKzE2Y,501
2
+ structured_llm/client.py,sha256=4MeToTVi-GA4eeJh3gZ_BJrrb0fEvrjdbFwjX3z6lmE,15601
3
+ structured_llm/errors.py,sha256=5dRCGBAG9DwXAAaxszhUXfahXaiYwwECAO8bNbBicho,1399
4
+ structured_llm/parser.py,sha256=B5UgSGVPT9zcUupO4_UmrCekU7xFPKf0rD59Qw6Um3I,3811
5
+ structured_llm/schema.py,sha256=B9RpDsOWd6wLGnfu4u0BkeWSg5VaxwDrWvtxRSUnXF0,4830
6
+ structured_llm-0.1.0.dist-info/METADATA,sha256=1QKbDGfBsSaDY5fED1550ApRQc6R65kSiGA2z91gzkk,3157
7
+ structured_llm-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ structured_llm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any