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.
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/PKG-INFO +1 -1
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/pyproject.toml +1 -1
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/pyproject.toml.orig +1 -1
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/claude_code.py +4 -3
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/bridge.py +35 -23
- polyllmkit-0.2.1/src/llmkit/bridge/structured.py +120 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/__main__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/_spawn.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/anthropic.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/google.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/adapters/openai.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/cli.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/client.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/config.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/sinks.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/bridge/stream.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/__main__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/cli.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/render/stream.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/__init__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/__main__.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/app.py +0 -0
- {polyllmkit-0.2.0 → polyllmkit-0.2.1}/src/llmkit/md/view/cli.py +0 -0
- {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.
|
|
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'
|
|
@@ -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.
|
|
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,
|
|
176
|
-
none, or ``(1, None)`` on an
|
|
177
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
88
|
-
enable_thinking:
|
|
89
|
-
schema:
|
|
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) ->
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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)``.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
328
|
-
except
|
|
329
|
-
|
|
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:
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|