polyllmkit 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,277 @@
1
+ """Typed, extensible provider/profile config — the generic half of what
2
+ zsh-ai's ``models.py`` used to do inline.
3
+
4
+ The model has three tables, all generic:
5
+
6
+ ``[defaults]`` fields merged into every provider (a partial
7
+ :class:`Provider`).
8
+ ``[providers.<name>]`` a named backend config: adapter (transport),
9
+ model, endpoint, key, tuning. Resolve a usable
10
+ one with :meth:`Config.resolve` (overlays defaults).
11
+ ``[profiles.<name>]`` a named map of *opaque* selector-key → provider
12
+ name. The library never interprets the keys — a
13
+ consumer (e.g. zsh-ai's "widgets": ask/modify/
14
+ question/fim) gives them meaning via
15
+ :meth:`Config.select`.
16
+
17
+ Extending the schema is by *subclassing*, not by stuffing untyped values
18
+ into a bag: parameterize :class:`ConfigParser` with a richer
19
+ :class:`Provider` subclass and/or override :meth:`ConfigParser.build` to
20
+ return a :class:`Config` subclass carrying extra typed sections. The
21
+ parser's return type tracks your subclass — no ``Any``, no post-hoc
22
+ mutation. The only escape hatch for genuinely-unknown *keys* is
23
+ :attr:`Provider.extra`, typed as :data:`TomlValue` (TOML's closed value
24
+ grammar), used purely for forward-compatible passthrough.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ from dataclasses import dataclass, field
31
+ from datetime import date, datetime, time
32
+ from typing import Generic, Mapping, Optional, Self, TypeAlias, TypeVar, cast
33
+
34
+ # Exactly what ``tomllib.load`` can produce — a closed, recursive set.
35
+ # Used for unknown passthrough keys and as the parser's input value type.
36
+ TomlValue: TypeAlias = (
37
+ "str | int | float | bool | datetime | date | time "
38
+ "| list[TomlValue] | dict[str, TomlValue]"
39
+ )
40
+
41
+ # Known provider fields (anything else in a [providers.*] table → .extra).
42
+ _PROVIDER_FIELDS = frozenset(
43
+ {
44
+ "model",
45
+ "adapter",
46
+ "endpoint",
47
+ "api_key",
48
+ "api_key_env",
49
+ "max_tokens",
50
+ "temperature",
51
+ "enable_thinking",
52
+ "stop",
53
+ "headers",
54
+ }
55
+ )
56
+
57
+
58
+ def _normalise_thinking(value: object) -> Optional[str]:
59
+ """Normalise an ``enable_thinking`` value to the bridge's tri-state
60
+ ``auto`` | ``true`` | ``false`` (or ``None`` if unspecified)."""
61
+ if value is None:
62
+ return None
63
+ if isinstance(value, bool):
64
+ return "true" if value else "false"
65
+ s = str(value).strip().lower()
66
+ if s in ("yes", "true", "1", "on"):
67
+ return "true"
68
+ if s in ("no", "false", "0", "off"):
69
+ return "false"
70
+ return "auto"
71
+
72
+
73
+ def _as_stop(value: object) -> tuple[str, ...]:
74
+ """Coerce a ``stop`` field (scalar or list) into a tuple of strings."""
75
+ if value is None:
76
+ return ()
77
+ if isinstance(value, (list, tuple)):
78
+ return tuple(str(v) for v in value)
79
+ return (str(value),)
80
+
81
+
82
+ def _as_table(value: object) -> dict[str, object]:
83
+ """A TOML table → dict, or {} for anything else (incl. missing)."""
84
+ return dict(value) if isinstance(value, Mapping) else {}
85
+
86
+
87
+ def _header_item(key: object, value: object) -> tuple[str, str]:
88
+ """Normalise one header entry. Names are case-insensitive per RFC 9110
89
+ (and lowercase on the wire in HTTP/2), so lowercase them — that is also
90
+ what makes the per-key merge across ``[defaults]`` / provider / CLI
91
+ reliable. Whitespace is stripped from both halves (h11 rejects padded
92
+ values at request time). Non-string values are stringified via ``str()``
93
+ (so TOML ``true`` lands as ``'True'``, Python's casing)."""
94
+ return str(key).strip().lower(), str(value).strip()
95
+
96
+
97
+ def _as_headers(value: object) -> dict[str, str]:
98
+ """Coerce a ``headers`` field into a str→str dict. Accepts a TOML table,
99
+ or a list of ``KEY=VALUE`` strings — the same shape the CLI ``--header``
100
+ flag and the zsh ``headers`` zstyle use, so either surface's idiom works.
101
+ Anything else (incl. missing) → ``{}``. Entries with an empty name are
102
+ dropped; see :func:`_header_item` for normalisation."""
103
+ if isinstance(value, (list, tuple)):
104
+ out: dict[str, str] = {}
105
+ for item in value:
106
+ key, _, val = str(item).partition("=")
107
+ key, val = _header_item(key, val)
108
+ if key:
109
+ out[key] = val
110
+ return out
111
+ return {
112
+ k: v
113
+ for k, v in (_header_item(kk, vv) for kk, vv in _as_table(value).items())
114
+ if k
115
+ }
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Provider:
120
+ """A backend config. Every field is optional — ``None`` means
121
+ "unspecified", so partial providers (and ``[defaults]``) compose via
122
+ :meth:`merged`. Hard defaults (adapter, endpoint, …) are applied by
123
+ the bridge at use time, NOT here, so a ``Provider`` only ever holds
124
+ what was actually configured.
125
+ """
126
+
127
+ model: Optional[str] = None
128
+ # "openai-compatible" | "anthropic" | "google" | "claude_code"
129
+ adapter: Optional[str] = None
130
+ endpoint: Optional[str] = None
131
+ api_key: Optional[str] = None
132
+ api_key_env: Optional[str] = None
133
+ max_tokens: Optional[int] = None
134
+ temperature: Optional[float] = None
135
+ enable_thinking: Optional[str] = None # auto|true|false
136
+ stop: tuple[str, ...] = ()
137
+ # Extra HTTP request headers, sent on every call by the openai/anthropic/
138
+ # google adapters (as the SDKs' ``default_headers`` / ``HttpOptions``). Used
139
+ # for gateway routing keys a gateway demands but the SDK won't set — e.g.
140
+ # OpenCode Zen's ``x-opencode-request``, which its qwen pool requires (absent
141
+ # it, zen 503s ``failover_exhausted``). Merges per-key over ``[defaults]``.
142
+ headers: Mapping[str, str] = field(default_factory=dict)
143
+ # Forward-compat passthrough for unknown [providers.*] keys.
144
+ extra: Mapping[str, "TomlValue"] = field(default_factory=dict)
145
+
146
+ @classmethod
147
+ def from_toml(cls, raw: Mapping[str, object]) -> Self:
148
+ """Build from a raw TOML table. Known keys are coerced into typed
149
+ fields; unrecognised keys are preserved verbatim in :attr:`extra`.
150
+ Subclasses that add fields override this to claim their own keys."""
151
+ extra = {k: v for k, v in raw.items() if k not in _PROVIDER_FIELDS}
152
+ return cls(
153
+ model=_opt_str(raw.get("model")),
154
+ adapter=_opt_str(raw.get("adapter")),
155
+ endpoint=_opt_str(raw.get("endpoint")),
156
+ api_key=_opt_str(raw.get("api_key")),
157
+ api_key_env=_opt_str(raw.get("api_key_env")),
158
+ max_tokens=_opt_int(raw.get("max_tokens")),
159
+ temperature=_opt_float(raw.get("temperature")),
160
+ enable_thinking=_normalise_thinking(raw.get("enable_thinking")),
161
+ stop=_as_stop(raw.get("stop")),
162
+ headers=_as_headers(raw.get("headers")),
163
+ extra=cast("Mapping[str, TomlValue]", extra),
164
+ )
165
+
166
+ def merged(self, base: Self) -> Self:
167
+ """Overlay ``self`` onto ``base`` (``self`` wins). Generic over
168
+ the concrete type — works field-by-field for any subclass, so a
169
+ richer provider's extra fields compose too. Mappings merge,
170
+ tuples fall back when empty, scalars pick non-``None``."""
171
+ out: dict[str, object] = {}
172
+ for f in dataclasses.fields(self):
173
+ out[f.name] = self._merge_field(
174
+ getattr(self, f.name), getattr(base, f.name)
175
+ )
176
+ return type(self)(**out) # type: ignore[arg-type] # preserves subclass type
177
+
178
+ @staticmethod
179
+ def _merge_field(self_val: object, base_val: object) -> object:
180
+ """Default per-field merge. Override in a subclass for fields that
181
+ need bespoke composition."""
182
+ if isinstance(self_val, Mapping) and isinstance(base_val, Mapping):
183
+ return {**base_val, **self_val}
184
+ if isinstance(self_val, tuple):
185
+ return self_val if self_val else base_val
186
+ return self_val if self_val is not None else base_val
187
+
188
+
189
+ def _opt_str(v: object) -> Optional[str]:
190
+ return None if v is None else str(v)
191
+
192
+
193
+ def _opt_int(v: object) -> Optional[int]:
194
+ return None if v is None else int(v) # type: ignore[call-overload]
195
+
196
+
197
+ def _opt_float(v: object) -> Optional[float]:
198
+ return None if v is None else float(v) # type: ignore[arg-type]
199
+
200
+
201
+ P = TypeVar("P", bound=Provider)
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class Config(Generic[P]):
206
+ """Parsed config. Parameterised by the provider type so subclasses can
207
+ carry a richer ``Provider`` (and add their own typed sections by
208
+ subclassing this and overriding :meth:`ConfigParser.build`)."""
209
+
210
+ defaults: P
211
+ providers: Mapping[str, P]
212
+ profiles: Mapping[str, Mapping[str, str]] # name → (opaque key → provider)
213
+
214
+ def resolve(self, name: str) -> P:
215
+ """The named provider with ``[defaults]`` overlaid beneath it."""
216
+ return self.providers[name].merged(self.defaults)
217
+
218
+ def select(self, profile: str, key: str) -> Optional[str]:
219
+ """Provider name for ``key`` in ``profile`` (key opaque), or None."""
220
+ return self.profiles.get(profile, {}).get(key)
221
+
222
+
223
+ class ConfigParser(Generic[P]):
224
+ """Parses the three known tables into a :class:`Config`. Extend by:
225
+
226
+ * setting :attr:`provider_cls` to a :class:`Provider` subclass, and/or
227
+ * overriding :meth:`build` to return a :class:`Config` subclass with
228
+ extra typed sections read from ``data``.
229
+
230
+ The base parser (``ConfigParser()``) yields ``Config[Provider]``.
231
+ """
232
+
233
+ provider_cls: type[P] = cast("type[P]", Provider)
234
+
235
+ def provider(self, raw: Mapping[str, object]) -> P:
236
+ return self.provider_cls.from_toml(raw)
237
+
238
+ def parse(self, data: Mapping[str, object]) -> Config[P]:
239
+ defaults = self.provider(_as_table(data.get("defaults")))
240
+ providers = {
241
+ name: self.provider(_as_table(v))
242
+ for name, v in _as_table(data.get("providers")).items()
243
+ }
244
+ profiles = {
245
+ name: {str(k): str(pv) for k, pv in _as_table(v).items()}
246
+ for name, v in _as_table(data.get("profiles")).items()
247
+ }
248
+ return self.build(defaults, providers, profiles, data)
249
+
250
+ def build(
251
+ self,
252
+ defaults: P,
253
+ providers: Mapping[str, P],
254
+ profiles: Mapping[str, Mapping[str, str]],
255
+ data: Mapping[str, object],
256
+ ) -> Config[P]:
257
+ """Assemble the final Config. Override to return a subclass that
258
+ reads extra tables from ``data`` (e.g. a typed ``widgets`` field)."""
259
+ return Config(defaults=defaults, providers=providers, profiles=profiles)
260
+
261
+
262
+ def load(path: str) -> Config[Provider]:
263
+ """Convenience: parse a TOML file with the base parser."""
264
+ import tomllib
265
+
266
+ with open(path, "rb") as f:
267
+ data = tomllib.load(f)
268
+ return ConfigParser().parse(data)
269
+
270
+
271
+ __all__ = [
272
+ "TomlValue",
273
+ "Provider",
274
+ "Config",
275
+ "ConfigParser",
276
+ "load",
277
+ ]
llmkit/bridge/sinks.py ADDED
@@ -0,0 +1,56 @@
1
+ """Output sinks for content / thinking streams.
2
+
3
+ A "sink" is the destination for one of the two logical streams that
4
+ :class:`llmkit.bridge.stream.StreamSplitter` produces. A caller picks each
5
+ via a string spec (e.g. the ``--content`` / ``--thinking`` flags of the
6
+ CLI); this module normalises that spec into something the splitter and the
7
+ chat command can use.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from typing import TextIO, Union
13
+
14
+ # `Sink` is the resolved form. ``'inline'`` is a thinking-side sentinel
15
+ # meaning "emit reasoning into the content sink, separated from real
16
+ # content by a blank line". Renderers then render thinking as ordinary
17
+ # prose; no wrapping tags or prefixes to confuse them.
18
+ Sink = Union[None, str, TextIO]
19
+
20
+
21
+ def open_target(spec: Union[str, TextIO]) -> Sink:
22
+ """Resolve a ``--content`` / ``--thinking`` spec to a sink.
23
+
24
+ Returns:
25
+ an already-open writable stream passed through as-is (in-process capture —
26
+ e.g. a ``StringIO`` from :func:`llmkit.bridge.chat_to_str`; the caller owns
27
+ it),
28
+ ``None`` for ``none`` (drop entirely),
29
+ the literal sentinel ``'inline'`` (the StreamSplitter interprets it),
30
+ ``sys.stdout`` for ``-``,
31
+ an open writable file for any other value (treated as a path).
32
+
33
+ Files are opened line-buffered so a downstream reader sees output
34
+ promptly.
35
+ """
36
+ if not isinstance(spec, str): # already-open TextIO (caller-owned capture)
37
+ return spec
38
+ if spec == "none":
39
+ return None
40
+ if spec == "-":
41
+ return sys.stdout
42
+ if spec == "inline":
43
+ return "inline"
44
+ return open(spec, "w", buffering=1)
45
+
46
+
47
+ def close_sink(sink: Sink) -> None:
48
+ """Close a sink if it owns its file handle."""
49
+ if sink is None or sink == "inline":
50
+ return
51
+ if sink in (sys.stdout, sys.stderr):
52
+ return
53
+ try:
54
+ sink.close() # type: ignore[union-attr]
55
+ except Exception:
56
+ pass
@@ -0,0 +1,161 @@
1
+ """Two-stream splitter for OpenAI-compatible chat responses.
2
+
3
+ Unifies the two server conventions for "reasoning":
4
+
5
+ 1. ``delta.reasoning_content`` — split-out API field (vLLM with
6
+ ``--reasoning-parser``, deepseek API, …)
7
+ 2. ``<think>…</think>`` tags inside ``delta.content`` (llama.cpp
8
+ default, ollama, anything that passes raw model output through)
9
+
10
+ Routes the unified streams to two sinks:
11
+
12
+ content the final answer text
13
+ thinking a writable (TextIO), ``None`` to drop, or the sentinel
14
+ ``"inline"`` — which merges reasoning into the content
15
+ sink, separated from real content by a blank line. No
16
+ tags, no prefixes; the markdown renderer treats it as
17
+ ordinary prose
18
+
19
+ Robust to ``<think>`` tags straddling chunk boundaries — the open or
20
+ close marker can be split across two ``feed_content_delta`` calls.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from typing import Optional, TextIO, cast
25
+
26
+ from .sinks import Sink
27
+
28
+
29
+ class StreamSplitter:
30
+ OPEN = "<think>"
31
+ CLOSE = "</think>"
32
+
33
+ def __init__(self, content_sink: Sink, thinking_sink: Sink) -> None:
34
+ self.content: Optional[TextIO] = (
35
+ None if content_sink in (None, "inline")
36
+ else cast(TextIO, content_sink)
37
+ )
38
+ self.inline = thinking_sink == "inline"
39
+ # Effective separate-sink thinking output: inline routes through
40
+ # content; None drops; otherwise a TextIO.
41
+ self._think_out: Optional[TextIO]
42
+ if self.inline:
43
+ self._think_out = self.content
44
+ elif thinking_sink is None:
45
+ self._think_out = None
46
+ else:
47
+ self._think_out = cast(TextIO, thinking_sink)
48
+
49
+ # Tag-parser state (case 2: <think> inside delta.content).
50
+ self._buf = ""
51
+ self._in_tag_think = False
52
+ # Inline-mode: True while we're emitting thinking text into the
53
+ # content sink, so transitions back to real content know to
54
+ # emit a blank-line separator.
55
+ self._inline_in_thinking = False
56
+
57
+ # ── public ──────────────────────────────────────────────────────────
58
+ def feed_content_delta(self, text: str) -> None:
59
+ if not text:
60
+ return
61
+ # API-field reasoning has ended — close the inline-thinking
62
+ # paragraph before content takes over.
63
+ self._end_inline_thinking()
64
+ self._buf += text
65
+ self._consume_buf()
66
+
67
+ def feed_reasoning_delta(self, text: str) -> None:
68
+ if not text:
69
+ return
70
+ self._begin_inline_thinking()
71
+ self._emit_thinking(text)
72
+
73
+ def finish(self) -> None:
74
+ if self._buf:
75
+ if self._in_tag_think:
76
+ self._emit_thinking(self._buf)
77
+ else:
78
+ self._emit_content(self._buf)
79
+ self._buf = ""
80
+ # Close any still-open inline-thinking paragraph.
81
+ self._end_inline_thinking()
82
+
83
+ # ── inline-mode helpers ─────────────────────────────────────────────
84
+ def _begin_inline_thinking(self) -> None:
85
+ """Inline mode only: emit a leading newline before the first
86
+ thinking chunk so the reasoning sits in its own paragraph."""
87
+ if self.inline and not self._inline_in_thinking:
88
+ self._emit_content("\n")
89
+ self._inline_in_thinking = True
90
+
91
+ def _end_inline_thinking(self) -> None:
92
+ """Inline mode only: terminate the thinking paragraph with a
93
+ blank line so subsequent content renders as a fresh paragraph."""
94
+ if self.inline and self._inline_in_thinking:
95
+ self._emit_content("\n\n")
96
+ self._inline_in_thinking = False
97
+
98
+ # ── low-level emitters ──────────────────────────────────────────────
99
+ def _emit_content(self, s: str) -> None:
100
+ if s and self.content is not None:
101
+ self.content.write(s)
102
+ self.content.flush()
103
+
104
+ def _emit_thinking(self, s: str) -> None:
105
+ """Inline mode routes through the content sink (separators are
106
+ bracketed by _begin/_end_inline_thinking). Otherwise writes to
107
+ the dedicated thinking sink."""
108
+ if not s:
109
+ return
110
+ if self.inline:
111
+ self._emit_content(s)
112
+ elif self._think_out is not None:
113
+ self._think_out.write(s)
114
+ self._think_out.flush()
115
+
116
+ # ── tag parser for <think>…</think> in delta.content ────────────────
117
+ def _consume_buf(self) -> None:
118
+ while self._buf:
119
+ if not self._in_tag_think:
120
+ idx = self._buf.find(self.OPEN)
121
+ if idx != -1:
122
+ pre = self._buf[:idx]
123
+ self._buf = self._buf[idx + len(self.OPEN):]
124
+ self._emit_content(pre)
125
+ self._in_tag_think = True
126
+ self._begin_inline_thinking()
127
+ continue
128
+ tail_keep = self._partial_tail(self.OPEN)
129
+ if tail_keep > 0:
130
+ self._emit_content(self._buf[:-tail_keep])
131
+ self._buf = self._buf[-tail_keep:]
132
+ else:
133
+ self._emit_content(self._buf)
134
+ self._buf = ""
135
+ return
136
+ else:
137
+ idx = self._buf.find(self.CLOSE)
138
+ if idx != -1:
139
+ inner = self._buf[:idx]
140
+ self._buf = self._buf[idx + len(self.CLOSE):]
141
+ self._emit_thinking(inner)
142
+ self._in_tag_think = False
143
+ self._end_inline_thinking()
144
+ continue
145
+ tail_keep = self._partial_tail(self.CLOSE)
146
+ if tail_keep > 0:
147
+ self._emit_thinking(self._buf[:-tail_keep])
148
+ self._buf = self._buf[-tail_keep:]
149
+ else:
150
+ self._emit_thinking(self._buf)
151
+ self._buf = ""
152
+ return
153
+
154
+ def _partial_tail(self, marker: str) -> int:
155
+ """Length of trailing buffer that could be the start of ``marker``."""
156
+ buf = self._buf
157
+ max_n = min(len(marker) - 1, len(buf))
158
+ for n in range(max_n, 0, -1):
159
+ if marker.startswith(buf[-n:]):
160
+ return n
161
+ return 0
llmkit/md/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """llmkit.md — render markdown to a terminal.
2
+
3
+ Two entry points, one rich/textual pipeline:
4
+
5
+ :mod:`llmkit.md.render` one-shot or live-streaming render to stdout
6
+ (``python -m llmkit.md.render``).
7
+ :mod:`llmkit.md.view` scrollable, follow-tailing modal viewer
8
+ (``python -m llmkit.md.view``).
9
+
10
+ The streaming renderer (:class:`llmkit.md.render.stream.LiveMarkdownStream`)
11
+ mirrors textual's ``Markdown.get_stream`` API, so callers can swap a rich
12
+ Console for a textual widget without restructuring.
13
+ """
@@ -0,0 +1 @@
1
+ """Markdown rendering: one-shot + live streaming (rich)."""
@@ -0,0 +1,8 @@
1
+ """``python -m llmkit.md.render`` → :func:`.cli.main`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())