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.
- llmkit/__init__.py +20 -0
- llmkit/bridge/__init__.py +52 -0
- llmkit/bridge/__main__.py +11 -0
- llmkit/bridge/adapters/__init__.py +14 -0
- llmkit/bridge/adapters/anthropic.py +154 -0
- llmkit/bridge/adapters/claude_code.py +166 -0
- llmkit/bridge/adapters/google.py +106 -0
- llmkit/bridge/adapters/openai.py +60 -0
- llmkit/bridge/bridge.py +374 -0
- llmkit/bridge/cli.py +174 -0
- llmkit/bridge/client.py +88 -0
- llmkit/bridge/config.py +277 -0
- llmkit/bridge/sinks.py +56 -0
- llmkit/bridge/stream.py +161 -0
- llmkit/md/__init__.py +13 -0
- llmkit/md/render/__init__.py +1 -0
- llmkit/md/render/__main__.py +8 -0
- llmkit/md/render/cli.py +231 -0
- llmkit/md/render/stream.py +244 -0
- llmkit/md/view/__init__.py +1 -0
- llmkit/md/view/__main__.py +8 -0
- llmkit/md/view/app.py +258 -0
- llmkit/md/view/cli.py +74 -0
- llmkit/py.typed +0 -0
- polyllmkit-0.1.0.dist-info/METADATA +17 -0
- polyllmkit-0.1.0.dist-info/RECORD +27 -0
- polyllmkit-0.1.0.dist-info/WHEEL +4 -0
llmkit/bridge/config.py
ADDED
|
@@ -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
|
llmkit/bridge/stream.py
ADDED
|
@@ -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)."""
|