polyllmkit 0.2.0__tar.gz → 0.2.1__tar.gz

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.
Files changed (29) hide show
  1. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/PKG-INFO +1 -1
  2. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/pyproject.toml +1 -1
  3. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/pyproject.toml.orig +1 -1
  4. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/claude_code.py +4 -3
  5. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/bridge.py +35 -23
  6. polyllmkit-0.2.1/src/llmkit/bridge/structured.py +120 -0
  7. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/__init__.py +0 -0
  8. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/__init__.py +0 -0
  9. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/__main__.py +0 -0
  10. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/_spawn.py +0 -0
  11. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/__init__.py +0 -0
  12. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/anthropic.py +0 -0
  13. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/google.py +0 -0
  14. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/openai.py +0 -0
  15. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/cli.py +0 -0
  16. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/client.py +0 -0
  17. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/config.py +0 -0
  18. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/sinks.py +0 -0
  19. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/stream.py +0 -0
  20. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/__init__.py +0 -0
  21. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/__init__.py +0 -0
  22. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/__main__.py +0 -0
  23. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/cli.py +0 -0
  24. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/stream.py +0 -0
  25. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/__init__.py +0 -0
  26. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/__main__.py +0 -0
  27. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/app.py +0 -0
  28. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/cli.py +0 -0
  29. {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: polyllmkit
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps.
5
5
  Requires-Dist: openai>=1.0 ; extra == 'all'
6
6
  Requires-Dist: claude-agent-sdk>=0.2 ; extra == 'all'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "polyllmkit"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
5
5
  requires-python = ">=3.11"
6
6
  dependencies = []
@@ -4,7 +4,7 @@
4
4
  # module-name below): consumers keep `import llmkit`, `pip install polyllmkit`.
5
5
  [project]
6
6
  name = "polyllmkit"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
9
9
  requires-python = ">=3.11" # tomllib (bridge.config) is stdlib only from 3.11
10
10
  # Base install pulls nothing — pick the subpackage(s) you need via extras.
@@ -172,9 +172,10 @@ def structured_claude_code(
172
172
  """Structured output via the SDK's native ``output_format`` (set in
173
173
  ``_build_options`` when the request carries a schema). Runs the single-turn
174
174
  query and returns ``(exit_code, structured_output)`` from the ResultMessage —
175
- the parsed object matching the schema, or ``(0, None)`` if the model produced
176
- none, or ``(1, None)`` on an SDK-reported error. No text parsing: the CLI does
177
- the schema enforcement + JSON parse and hands back a Python object."""
175
+ the parsed object matching the schema, ``(0, None)`` if the model produced
176
+ none (``chat_structured`` turns that into a failure), or ``(1, None)`` on an
177
+ SDK-reported error. No text parsing: the CLI does the schema enforcement +
178
+ JSON parse and hands back a Python object."""
178
179
  sdk = _require_sdk()
179
180
  import anyio # bundled with the SDK
180
181
 
@@ -15,12 +15,14 @@ failure semantics. ``complete`` (FIM) remains ``openai-compatible``-only.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import contextlib
18
19
  import dataclasses
19
20
  import io
20
21
  import os
21
22
  import sys
23
+ from collections.abc import Callable
22
24
  from dataclasses import dataclass
23
- from typing import Any, Callable, Optional, TextIO
25
+ from typing import Any, TextIO
24
26
 
25
27
  from .client import Deadline, build_client
26
28
  from .config import Config, Provider
@@ -43,7 +45,7 @@ ADAPTERS = ("openai-compatible", "anthropic", "google", "claude_code")
43
45
  _COMPLETE_ADAPTERS = frozenset({"openai-compatible"})
44
46
 
45
47
 
46
- def adapter_supports_complete(adapter: Optional[str]) -> bool:
48
+ def adapter_supports_complete(adapter: str | None) -> bool:
47
49
  """Whether ``adapter`` (``None`` → the default) can serve ``complete`` (FIM).
48
50
 
49
51
  Queryable up front so a consumer can disable a FIM keybinding / widget for
@@ -84,9 +86,9 @@ class ChatRequest:
84
86
  """
85
87
 
86
88
  user: str
87
- system: Optional[str] = None
88
- enable_thinking: Optional[str] = None
89
- schema: Optional[dict] = None
89
+ system: str | None = None
90
+ enable_thinking: str | None = None
91
+ schema: dict | None = None
90
92
  schema_name: str = "emit"
91
93
  schema_description: str = ""
92
94
 
@@ -125,7 +127,7 @@ def with_defaults(provider: Provider) -> Provider:
125
127
 
126
128
 
127
129
  # ── status events ───────────────────────────────────────────────────────────
128
- def _open_status(path: str) -> Optional[TextIO]:
130
+ def _open_status(path: str) -> TextIO | None:
129
131
  """Open a status file (regular file OR fifo) for line-buffered writes.
130
132
  O_RDWR | O_APPEND | O_NONBLOCK so it works against either kind without
131
133
  blocking on fifo connect (and without truncating regular files).
@@ -139,7 +141,7 @@ def _open_status(path: str) -> Optional[TextIO]:
139
141
  return None
140
142
 
141
143
 
142
- def _write_status(fp: Optional[TextIO], event: str) -> None:
144
+ def _write_status(fp: TextIO | None, event: str) -> None:
143
145
  """Best-effort status line write. Never raises."""
144
146
  if fp is None:
145
147
  return
@@ -157,7 +159,7 @@ class _Emitter:
157
159
  :meth:`signal_streaming` directly when a chunk arrives that carries
158
160
  neither."""
159
161
 
160
- def __init__(self, splitter: StreamSplitter, status_fp: Optional[TextIO]) -> None:
162
+ def __init__(self, splitter: StreamSplitter, status_fp: TextIO | None) -> None:
161
163
  self._splitter = splitter
162
164
  self._status_fp = status_fp
163
165
  self._started = False
@@ -221,17 +223,13 @@ def _run_chat(
221
223
  finally:
222
224
  # ALWAYS flush — interrupts and exceptions still get the close wrap
223
225
  # emitted, so downstream readers see a complete document.
224
- try:
226
+ with contextlib.suppress(Exception):
225
227
  splitter.finish()
226
- except Exception:
227
- pass
228
228
  close_sink(content_sink)
229
229
  close_sink(thinking_sink)
230
230
  if status_fp is not None:
231
- try:
231
+ with contextlib.suppress(OSError):
232
232
  status_fp.close()
233
- except OSError:
234
- pass
235
233
  return rc
236
234
 
237
235
 
@@ -309,31 +307,45 @@ def chat_structured(
309
307
  status_file: str = "",
310
308
  ) -> tuple[int, Any]:
311
309
  """Structured output: for a ``request`` carrying a ``schema``, capture the
312
- model's schema-filled JSON and return ``(exit_code, parsed)``. Returns
313
- ``(code, None)`` on a non-zero exit or unparseable output — the caller
314
- decides whether to fall back. Thinking is off (forced tool-use excludes it)."""
315
- import json
310
+ model's schema-filled JSON and return ``(exit_code, parsed)``. A non-zero
311
+ code always comes with ``None``, and ``0`` always comes with an object that
312
+ fits the schema: a reply with no usable JSON is a failure (code 1, reason
313
+ on stderr), never a silent success. The reply is parsed leniently (see
314
+ :mod:`llmkit.bridge.structured`) because an openai-compatible server may
315
+ ignore ``response_format`` and answer in fenced prose. Thinking is off
316
+ (forced tool-use excludes it)."""
317
+ from .structured import StructuredOutputError, conform, parse_structured
316
318
 
317
319
  # claude_code enforces the schema via the SDK's native output_format and returns
318
320
  # a parsed object in ResultMessage.structured_output — no text round-trip.
319
321
  if provider.adapter == "claude_code":
320
322
  from .adapters.claude_code import structured_claude_code
321
- return structured_claude_code(with_defaults(provider), request)
323
+ code, data = structured_claude_code(with_defaults(provider), request)
324
+ if code != 0:
325
+ return code, None
326
+ try:
327
+ if data is None:
328
+ raise StructuredOutputError("the CLI returned no structured_output")
329
+ return 0, conform(data, request.schema)
330
+ except StructuredOutputError as e:
331
+ print(f"llmkit: structured output: {e}", file=sys.stderr)
332
+ return 1, None
322
333
 
323
334
  code, text = chat_to_str(provider, request, thinking="none", status_file=status_file)
324
335
  if code != 0:
325
336
  return code, None
326
337
  try:
327
- return code, json.loads(text)
328
- except (ValueError, TypeError):
329
- return code, None
338
+ return 0, parse_structured(text, request.schema)
339
+ except StructuredOutputError as e:
340
+ print(f"llmkit: structured output: {e}", file=sys.stderr)
341
+ return 1, None
330
342
 
331
343
 
332
344
  def complete(
333
345
  provider: Provider,
334
346
  request: CompleteRequest,
335
347
  *,
336
- out: Optional[TextIO] = None,
348
+ out: TextIO | None = None,
337
349
  ) -> int:
338
350
  """Run a text-completion (FIM) request, streaming text to ``out``
339
351
  (default stdout). ``openai-compatible`` only."""
@@ -0,0 +1,120 @@
1
+ """Recover a schema-shaped object from a model's structured-output reply.
2
+
3
+ Adapters ask for structured output natively (``response_format`` for
4
+ openai-compatible, a forced tool for anthropic, ``output_format`` for
5
+ claude_code), but an openai-compatible server is free to ignore
6
+ ``response_format``: e.g. GLM at ollama.com answers in ordinary content, with
7
+ the JSON fenced in ````` ```json ````` and missing the schema's wrapper object
8
+ (a bare ``[...]`` where the schema says ``{"symbols": [...]}``). So the reply
9
+ is parsed leniently here, then checked against the schema, and any failure
10
+ raises :class:`StructuredOutputError` with the reason. Callers must never
11
+ mistake "nothing usable came back" for success.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from typing import Any
19
+
20
+ _FENCE = re.compile(r"```[A-Za-z0-9_-]*[ \t]*\n(.*?)\n?```", re.DOTALL)
21
+ _TYPES: dict[str, tuple[type, ...]] = {
22
+ "object": (dict,),
23
+ "array": (list,),
24
+ "string": (str,),
25
+ "integer": (int,),
26
+ "number": (int, float),
27
+ "boolean": (bool,),
28
+ "null": (type(None),),
29
+ }
30
+
31
+
32
+ class StructuredOutputError(ValueError):
33
+ """The reply held no JSON matching the requested schema."""
34
+
35
+
36
+ def extract_json(text: str) -> Any:
37
+ """The JSON value in ``text``: the whole text, else the first fenced code
38
+ block that parses, else the first ``{``/``[`` value found in prose."""
39
+ s = text.strip()
40
+ if not s:
41
+ raise StructuredOutputError("empty reply")
42
+ try:
43
+ return json.loads(s)
44
+ except ValueError:
45
+ pass
46
+ for m in _FENCE.finditer(s):
47
+ try:
48
+ return json.loads(m.group(1))
49
+ except ValueError:
50
+ continue
51
+ dec = json.JSONDecoder()
52
+ for i, ch in enumerate(s):
53
+ if ch in "{[":
54
+ try:
55
+ return dec.raw_decode(s, i)[0]
56
+ except ValueError:
57
+ continue
58
+ raise StructuredOutputError(f"no JSON value in reply: {s[:120]!r}")
59
+
60
+
61
+ def _sole_array_property(schema: dict[str, Any]) -> str | None:
62
+ """The one array-typed property of an object schema, if it has exactly
63
+ one property (or exactly one required one) and that property is an array."""
64
+ props = schema.get("properties") or {}
65
+ required = schema.get("required") or []
66
+ names = list(props) if len(props) == 1 else list(required)
67
+ if len(names) == 1 and (props.get(names[0]) or {}).get("type") == "array":
68
+ return names[0]
69
+ return None
70
+
71
+
72
+ def _check(value: Any, schema: dict[str, Any], path: str) -> None:
73
+ """A JSON Schema subset: type, enum, required, properties, items. Enough to
74
+ tell a schema-shaped reply from a wrong one, without a jsonschema dep."""
75
+ t = schema.get("type")
76
+ types = [t] if isinstance(t, str) else (t or [])
77
+ if types:
78
+ ok = any(
79
+ isinstance(value, _TYPES.get(name, ()))
80
+ # bool is an int subclass; JSON keeps them apart.
81
+ and not (isinstance(value, bool) and name in ("integer", "number"))
82
+ for name in types
83
+ )
84
+ if not ok:
85
+ raise StructuredOutputError(
86
+ f"{path or 'reply'}: expected {'/'.join(types)}, "
87
+ f"got {type(value).__name__}"
88
+ )
89
+ if "enum" in schema and value not in schema["enum"]:
90
+ raise StructuredOutputError(f"{path or 'reply'}: {value!r} not in enum")
91
+ if isinstance(value, dict):
92
+ for key in schema.get("required") or []:
93
+ if key not in value:
94
+ raise StructuredOutputError(f"{path or 'reply'}: missing {key!r}")
95
+ for key, sub in (schema.get("properties") or {}).items():
96
+ if key in value and isinstance(sub, dict):
97
+ _check(value[key], sub, f"{path}.{key}" if path else key)
98
+ if isinstance(value, list) and isinstance(schema.get("items"), dict):
99
+ for i, item in enumerate(value):
100
+ _check(item, schema["items"], f"{path}[{i}]")
101
+
102
+
103
+ def conform(value: Any, schema: dict[str, Any] | None) -> Any:
104
+ """``value`` checked against ``schema``. A bare array where the schema is an
105
+ object with a single array property is wrapped into that property first
106
+ (the shape models produce when they skip the wrapper). Raises
107
+ :class:`StructuredOutputError` if it still doesn't fit."""
108
+ if not schema:
109
+ return value
110
+ if isinstance(value, list) and schema.get("type") == "object":
111
+ key = _sole_array_property(schema)
112
+ if key is not None:
113
+ value = {key: value}
114
+ _check(value, schema, "")
115
+ return value
116
+
117
+
118
+ def parse_structured(text: str, schema: dict[str, Any] | None) -> Any:
119
+ """Parse a structured-output reply and check it against ``schema``."""
120
+ return conform(extract_json(text), schema)