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/bridge.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""The dataclass-first bridge API.
|
|
2
|
+
|
|
3
|
+
The reusable surface: callers build a :class:`~llmkit.bridge.config.Provider`
|
|
4
|
+
(however they like — from TOML, env, hardcoded) plus a request, and call
|
|
5
|
+
:func:`chat` / :func:`complete`. The flag-based CLI (:mod:`llmkit.bridge.cli`)
|
|
6
|
+
is just one adapter over this; it owns no logic the API doesn't.
|
|
7
|
+
|
|
8
|
+
Four adapters back :func:`chat`: the default ``openai-compatible`` HTTP path,
|
|
9
|
+
``anthropic`` (native Messages API), ``google`` (native Gemini API), and
|
|
10
|
+
``claude_code`` (Claude Agent SDK, chat-only). A "producer" pumps
|
|
11
|
+
reasoning/content deltas into an :class:`_Emitter`; :func:`_run_chat` owns
|
|
12
|
+
the sink/splitter/status lifecycle so every adapter shares identical
|
|
13
|
+
failure semantics. ``complete`` (FIM) remains ``openai-compatible``-only.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import io
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Callable, Optional, TextIO
|
|
24
|
+
|
|
25
|
+
from .client import build_client
|
|
26
|
+
from .config import Config, Provider
|
|
27
|
+
from .sinks import close_sink, open_target
|
|
28
|
+
from .stream import StreamSplitter
|
|
29
|
+
|
|
30
|
+
DEFAULT_ENDPOINT = "http://localhost:11434/v1"
|
|
31
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
32
|
+
DEFAULT_TEMPERATURE = 0.2
|
|
33
|
+
DEFAULT_ADAPTER = "openai-compatible"
|
|
34
|
+
|
|
35
|
+
# Every transport adapter :func:`chat` can dispatch to.
|
|
36
|
+
ADAPTERS = ("openai-compatible", "anthropic", "google", "claude_code")
|
|
37
|
+
|
|
38
|
+
# Adapters that can serve ``complete`` (text-completion / FIM). Only the
|
|
39
|
+
# openai-compatible transport exposes a /v1/completions FIM endpoint; the
|
|
40
|
+
# native chat protocols (anthropic, google) and claude_code have no FIM
|
|
41
|
+
# equivalent. Kept as data so the capability is queryable — see
|
|
42
|
+
# :func:`adapter_supports_complete`.
|
|
43
|
+
_COMPLETE_ADAPTERS = frozenset({"openai-compatible"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def adapter_supports_complete(adapter: Optional[str]) -> bool:
|
|
47
|
+
"""Whether ``adapter`` (``None`` → the default) can serve ``complete`` (FIM).
|
|
48
|
+
|
|
49
|
+
Queryable up front so a consumer can disable a FIM keybinding / widget for
|
|
50
|
+
an incompatible provider or profile, instead of relying on the runtime
|
|
51
|
+
rejection inside :func:`complete`."""
|
|
52
|
+
return (adapter or DEFAULT_ADAPTER) in _COMPLETE_ADAPTERS
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def provider_supports_complete(provider: Provider) -> bool:
|
|
56
|
+
""":func:`adapter_supports_complete` for a provider's configured adapter."""
|
|
57
|
+
return adapter_supports_complete(provider.adapter)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def profile_supports_complete(config: Config[Provider], profile: str, key: str) -> bool:
|
|
61
|
+
"""Whether the provider a ``profile``'s ``key`` (widget) selects can serve
|
|
62
|
+
``complete`` (FIM). ``False`` if the key selects no provider. Lets a
|
|
63
|
+
consumer answer "is FIM available for this profile+widget?" — e.g. zsh-ai
|
|
64
|
+
disabling its FIM keybind for a profile whose ``fim`` widget points at a
|
|
65
|
+
chat-only provider — without constructing a request."""
|
|
66
|
+
name = config.select(profile, key)
|
|
67
|
+
if name is None:
|
|
68
|
+
return False
|
|
69
|
+
return provider_supports_complete(config.resolve(name))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ChatRequest:
|
|
74
|
+
"""A single chat turn. ``enable_thinking`` (auto|true|false), if set,
|
|
75
|
+
overrides the provider's configured value for this call.
|
|
76
|
+
|
|
77
|
+
``schema`` requests *structured output*: a JSON Schema the model must fill.
|
|
78
|
+
Adapters enforce it natively — anthropic via a forced ``tool_use`` (the tool
|
|
79
|
+
input streams as content), openai-compatible via ``response_format`` — and
|
|
80
|
+
the filled JSON is what lands in the content sink, so a caller gets a
|
|
81
|
+
conformant object instead of parsing free-form prose. ``schema_name`` /
|
|
82
|
+
``schema_description`` label the tool for the model. Thinking is forced off
|
|
83
|
+
when a schema is set (forced tool-use and extended thinking don't combine).
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
user: str
|
|
87
|
+
system: Optional[str] = None
|
|
88
|
+
enable_thinking: Optional[str] = None
|
|
89
|
+
schema: Optional[dict] = None
|
|
90
|
+
schema_name: str = "emit"
|
|
91
|
+
schema_description: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class CompleteRequest:
|
|
96
|
+
"""A text-completion (FIM) request. ``openai-compatible`` only."""
|
|
97
|
+
|
|
98
|
+
prompt: str
|
|
99
|
+
suffix: str = ""
|
|
100
|
+
stop: tuple[str, ...] = ()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def with_defaults(provider: Provider) -> Provider:
|
|
104
|
+
"""Fill the hard defaults a producer needs onto an otherwise-partial
|
|
105
|
+
provider. Returns a base :class:`Provider` (producers read base fields
|
|
106
|
+
only). Idempotent."""
|
|
107
|
+
return Provider(
|
|
108
|
+
model=provider.model,
|
|
109
|
+
adapter=provider.adapter or DEFAULT_ADAPTER,
|
|
110
|
+
endpoint=provider.endpoint or DEFAULT_ENDPOINT,
|
|
111
|
+
api_key=provider.api_key,
|
|
112
|
+
api_key_env=provider.api_key_env,
|
|
113
|
+
max_tokens=provider.max_tokens
|
|
114
|
+
if provider.max_tokens is not None
|
|
115
|
+
else DEFAULT_MAX_TOKENS,
|
|
116
|
+
temperature=provider.temperature
|
|
117
|
+
if provider.temperature is not None
|
|
118
|
+
else DEFAULT_TEMPERATURE,
|
|
119
|
+
enable_thinking=provider.enable_thinking or "auto",
|
|
120
|
+
stop=provider.stop,
|
|
121
|
+
headers=provider.headers,
|
|
122
|
+
extra=provider.extra,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── status events ───────────────────────────────────────────────────────────
|
|
127
|
+
def _open_status(path: str) -> Optional[TextIO]:
|
|
128
|
+
"""Open a status file (regular file OR fifo) for line-buffered writes.
|
|
129
|
+
O_RDWR | O_APPEND | O_NONBLOCK so it works against either kind without
|
|
130
|
+
blocking on fifo connect (and without truncating regular files).
|
|
131
|
+
Returns None if disabled or open fails — status writes are best-effort."""
|
|
132
|
+
if not path:
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
fd = os.open(path, os.O_RDWR | os.O_APPEND | os.O_NONBLOCK)
|
|
136
|
+
return os.fdopen(fd, "w", buffering=1)
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _write_status(fp: Optional[TextIO], event: str) -> None:
|
|
142
|
+
"""Best-effort status line write. Never raises."""
|
|
143
|
+
if fp is None:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
fp.write(event + "\n")
|
|
147
|
+
fp.flush()
|
|
148
|
+
except OSError:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _Emitter:
|
|
153
|
+
"""Routes a producer's content/reasoning deltas into the splitter and
|
|
154
|
+
fires the one-shot ``streaming`` status event on the first sign of life
|
|
155
|
+
from the backend (reasoning OR content). Producers call
|
|
156
|
+
:meth:`signal_streaming` directly when a chunk arrives that carries
|
|
157
|
+
neither."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, splitter: StreamSplitter, status_fp: Optional[TextIO]) -> None:
|
|
160
|
+
self._splitter = splitter
|
|
161
|
+
self._status_fp = status_fp
|
|
162
|
+
self._started = False
|
|
163
|
+
|
|
164
|
+
def signal_streaming(self) -> None:
|
|
165
|
+
if not self._started:
|
|
166
|
+
_write_status(self._status_fp, "streaming")
|
|
167
|
+
self._started = True
|
|
168
|
+
|
|
169
|
+
def feed_content(self, text: str) -> None:
|
|
170
|
+
if text:
|
|
171
|
+
self.signal_streaming()
|
|
172
|
+
self._splitter.feed_content_delta(text)
|
|
173
|
+
|
|
174
|
+
def feed_reasoning(self, text: str) -> None:
|
|
175
|
+
if text:
|
|
176
|
+
self.signal_streaming()
|
|
177
|
+
self._splitter.feed_reasoning_delta(text)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# A producer drives the backend and pumps deltas into the emitter. It raises
|
|
181
|
+
# on error (caught by _run_chat) and returns None on success.
|
|
182
|
+
Producer = Callable[[Provider, ChatRequest, _Emitter], None]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_chat(
|
|
186
|
+
provider: Provider,
|
|
187
|
+
request: ChatRequest,
|
|
188
|
+
content: str | TextIO,
|
|
189
|
+
thinking: str,
|
|
190
|
+
status_file: str,
|
|
191
|
+
producer: Producer,
|
|
192
|
+
) -> int:
|
|
193
|
+
"""Own the sink/splitter/status lifecycle and run ``producer`` inside it.
|
|
194
|
+
Shared by every adapter so the failure semantics (status events, exit
|
|
195
|
+
codes, guaranteed ``splitter.finish()``) are identical."""
|
|
196
|
+
content_sink = open_target(content)
|
|
197
|
+
if content_sink == "inline":
|
|
198
|
+
print(
|
|
199
|
+
'llmkit: content sink cannot be "inline" '
|
|
200
|
+
"(that's a thinking-sink mode only)",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
return 2
|
|
204
|
+
thinking_sink = open_target(thinking)
|
|
205
|
+
splitter = StreamSplitter(content_sink, thinking_sink)
|
|
206
|
+
status_fp = _open_status(status_file)
|
|
207
|
+
emitter = _Emitter(splitter, status_fp)
|
|
208
|
+
|
|
209
|
+
rc = 0
|
|
210
|
+
try:
|
|
211
|
+
producer(provider, request, emitter)
|
|
212
|
+
_write_status(status_fp, "complete")
|
|
213
|
+
except KeyboardInterrupt:
|
|
214
|
+
_write_status(status_fp, "interrupted")
|
|
215
|
+
rc = 130
|
|
216
|
+
except Exception as e:
|
|
217
|
+
_write_status(status_fp, "error")
|
|
218
|
+
print(f"llmkit: {e}", file=sys.stderr)
|
|
219
|
+
rc = 1
|
|
220
|
+
finally:
|
|
221
|
+
# ALWAYS flush — interrupts and exceptions still get the close wrap
|
|
222
|
+
# emitted, so downstream readers see a complete document.
|
|
223
|
+
try:
|
|
224
|
+
splitter.finish()
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
close_sink(content_sink)
|
|
228
|
+
close_sink(thinking_sink)
|
|
229
|
+
if status_fp is not None:
|
|
230
|
+
try:
|
|
231
|
+
status_fp.close()
|
|
232
|
+
except OSError:
|
|
233
|
+
pass
|
|
234
|
+
return rc
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def chat(
|
|
238
|
+
provider: Provider,
|
|
239
|
+
request: ChatRequest,
|
|
240
|
+
*,
|
|
241
|
+
content: str | TextIO = "-",
|
|
242
|
+
thinking: str = "none",
|
|
243
|
+
status_file: str = "",
|
|
244
|
+
) -> int:
|
|
245
|
+
"""Run one chat turn. ``content`` / ``thinking`` are sink specs (``-``,
|
|
246
|
+
a path, ``none``, or — for thinking — ``inline``); ``content`` may also be an
|
|
247
|
+
already-open writable stream for in-process capture (see :func:`chat_to_str`).
|
|
248
|
+
``status_file`` is an optional file/fifo path for
|
|
249
|
+
``streaming``/``complete``/``error`` events.
|
|
250
|
+
|
|
251
|
+
Adapter is chosen from ``provider.adapter``. The request's
|
|
252
|
+
``enable_thinking`` overrides the provider's configured value."""
|
|
253
|
+
provider = with_defaults(provider)
|
|
254
|
+
eff_thinking = request.enable_thinking or provider.enable_thinking or "auto"
|
|
255
|
+
provider = dataclasses.replace(provider, enable_thinking=eff_thinking)
|
|
256
|
+
|
|
257
|
+
# Adapters are imported lazily so the default openai path (and --help) never
|
|
258
|
+
# pays for an optional dependency that may not be installed.
|
|
259
|
+
producer: Producer
|
|
260
|
+
if provider.adapter == "claude_code":
|
|
261
|
+
from .adapters.claude_code import stream_claude_code
|
|
262
|
+
|
|
263
|
+
producer = stream_claude_code
|
|
264
|
+
elif provider.adapter == "anthropic":
|
|
265
|
+
from .adapters.anthropic import stream_anthropic
|
|
266
|
+
|
|
267
|
+
producer = stream_anthropic
|
|
268
|
+
elif provider.adapter == "google":
|
|
269
|
+
from .adapters.google import stream_google
|
|
270
|
+
|
|
271
|
+
producer = stream_google
|
|
272
|
+
else:
|
|
273
|
+
from .adapters.openai import stream_openai
|
|
274
|
+
|
|
275
|
+
producer = stream_openai
|
|
276
|
+
return _run_chat(provider, request, content, thinking, status_file, producer)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class _StrSink(io.StringIO):
|
|
280
|
+
"""A ``StringIO`` whose ``close()`` is a no-op, so ``_run_chat``'s
|
|
281
|
+
finally-block close leaves the buffer readable — :func:`chat_to_str` reads
|
|
282
|
+
``getvalue()`` after the run returns."""
|
|
283
|
+
|
|
284
|
+
def close(self) -> None: # noqa: D401 — intentional no-op
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def chat_to_str(
|
|
289
|
+
provider: Provider,
|
|
290
|
+
request: ChatRequest,
|
|
291
|
+
*,
|
|
292
|
+
thinking: str = "none",
|
|
293
|
+
status_file: str = "",
|
|
294
|
+
) -> tuple[int, str]:
|
|
295
|
+
"""Run one chat turn and return ``(exit_code, content)`` in-process — the
|
|
296
|
+
content stream is captured to a string, no temp file. Failure semantics match
|
|
297
|
+
:func:`chat` (non-zero code on error); the partial content captured so far is
|
|
298
|
+
still returned."""
|
|
299
|
+
sink = _StrSink()
|
|
300
|
+
code = chat(provider, request, content=sink, thinking=thinking, status_file=status_file)
|
|
301
|
+
return code, sink.getvalue()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def chat_structured(
|
|
305
|
+
provider: Provider,
|
|
306
|
+
request: ChatRequest,
|
|
307
|
+
*,
|
|
308
|
+
status_file: str = "",
|
|
309
|
+
) -> tuple[int, Any]:
|
|
310
|
+
"""Structured output: for a ``request`` carrying a ``schema``, capture the
|
|
311
|
+
model's schema-filled JSON and return ``(exit_code, parsed)``. Returns
|
|
312
|
+
``(code, None)`` on a non-zero exit or unparseable output — the caller
|
|
313
|
+
decides whether to fall back. Thinking is off (forced tool-use excludes it)."""
|
|
314
|
+
import json
|
|
315
|
+
|
|
316
|
+
# claude_code enforces the schema via the SDK's native output_format and returns
|
|
317
|
+
# a parsed object in ResultMessage.structured_output — no text round-trip.
|
|
318
|
+
if provider.adapter == "claude_code":
|
|
319
|
+
from .adapters.claude_code import structured_claude_code
|
|
320
|
+
return structured_claude_code(with_defaults(provider), request)
|
|
321
|
+
|
|
322
|
+
code, text = chat_to_str(provider, request, thinking="none", status_file=status_file)
|
|
323
|
+
if code != 0:
|
|
324
|
+
return code, None
|
|
325
|
+
try:
|
|
326
|
+
return code, json.loads(text)
|
|
327
|
+
except (ValueError, TypeError):
|
|
328
|
+
return code, None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def complete(
|
|
332
|
+
provider: Provider,
|
|
333
|
+
request: CompleteRequest,
|
|
334
|
+
*,
|
|
335
|
+
out: Optional[TextIO] = None,
|
|
336
|
+
) -> int:
|
|
337
|
+
"""Run a text-completion (FIM) request, streaming text to ``out``
|
|
338
|
+
(default stdout). ``openai-compatible`` only."""
|
|
339
|
+
out = out if out is not None else sys.stdout
|
|
340
|
+
provider = with_defaults(provider)
|
|
341
|
+
if not provider_supports_complete(provider):
|
|
342
|
+
print(
|
|
343
|
+
f"llmkit: FIM (complete) supports only the openai-compatible "
|
|
344
|
+
f"adapter, not '{provider.adapter}'. Point the provider at an "
|
|
345
|
+
f"openai-compatible backend. (Query "
|
|
346
|
+
f"llmkit.bridge.adapter_supports_complete to gate this up front.)",
|
|
347
|
+
file=sys.stderr,
|
|
348
|
+
)
|
|
349
|
+
return 2
|
|
350
|
+
|
|
351
|
+
client = build_client(provider)
|
|
352
|
+
kwargs: dict[str, Any] = dict(
|
|
353
|
+
model=provider.model,
|
|
354
|
+
prompt=request.prompt,
|
|
355
|
+
max_tokens=provider.max_tokens,
|
|
356
|
+
temperature=provider.temperature,
|
|
357
|
+
)
|
|
358
|
+
if request.suffix:
|
|
359
|
+
kwargs["suffix"] = request.suffix
|
|
360
|
+
if request.stop:
|
|
361
|
+
kwargs["stop"] = list(request.stop)
|
|
362
|
+
kwargs["stream"] = True
|
|
363
|
+
try:
|
|
364
|
+
for chunk in client.completions.create(**kwargs):
|
|
365
|
+
t = chunk.choices[0].text if chunk.choices else ""
|
|
366
|
+
if t:
|
|
367
|
+
out.write(t)
|
|
368
|
+
out.flush()
|
|
369
|
+
except KeyboardInterrupt:
|
|
370
|
+
return 130
|
|
371
|
+
except Exception as e:
|
|
372
|
+
print(f"llmkit: {e}", file=sys.stderr)
|
|
373
|
+
return 1
|
|
374
|
+
return 0
|
llmkit/bridge/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Flag-based CLI — a thin adapter over the dataclass API in
|
|
2
|
+
:mod:`llmkit.bridge.bridge`.
|
|
3
|
+
|
|
4
|
+
Two subcommands:
|
|
5
|
+
|
|
6
|
+
chat chat-completions (openai-compatible or claude_code)
|
|
7
|
+
complete text-completions with FIM ``suffix`` (openai-compatible only)
|
|
8
|
+
|
|
9
|
+
This is the shape a host like a shell plugin drives: it resolves its own
|
|
10
|
+
config (zstyle, env, …) and passes the result as flags. Everything here just
|
|
11
|
+
maps those flags onto a :class:`Provider` + request and calls ``chat`` /
|
|
12
|
+
``complete``; no logic lives here that the API doesn't already own.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
from . import bridge
|
|
23
|
+
from .config import Provider, _header_item
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_common(s: argparse.ArgumentParser) -> None:
|
|
27
|
+
s.add_argument("--model", required=True)
|
|
28
|
+
s.add_argument("--max-tokens", type=int, default=1024)
|
|
29
|
+
s.add_argument("--temperature", type=float, default=0.2)
|
|
30
|
+
s.add_argument(
|
|
31
|
+
"--endpoint",
|
|
32
|
+
default=os.environ.get("LLMKIT_ENDPOINT", "http://localhost:11434/v1"),
|
|
33
|
+
)
|
|
34
|
+
s.add_argument("--api-key", default="")
|
|
35
|
+
s.add_argument("--api-key-env", default="")
|
|
36
|
+
s.add_argument(
|
|
37
|
+
"--header",
|
|
38
|
+
action="append",
|
|
39
|
+
default=[],
|
|
40
|
+
metavar="KEY=VALUE",
|
|
41
|
+
help="extra HTTP request header (repeatable). Sent on every call by the "
|
|
42
|
+
"HTTP adapters (openai-compatible/anthropic/google); claude_code has no "
|
|
43
|
+
"HTTP client and ignores it. VALUE supports $VAR/${VAR} env expansion "
|
|
44
|
+
"plus the ${uuid}/${epoch} dynamic tokens (braces required; there is no "
|
|
45
|
+
"escape for a literal $). Use for gateway routing keys the SDK won't "
|
|
46
|
+
"set (e.g. --header x-opencode-request=zsh-ai for OpenCode Zen).",
|
|
47
|
+
)
|
|
48
|
+
s.add_argument(
|
|
49
|
+
"--adapter",
|
|
50
|
+
choices=["openai-compatible", "claude_code", "anthropic", "google"],
|
|
51
|
+
default=os.environ.get("LLMKIT_ADAPTER", "openai-compatible"),
|
|
52
|
+
help="transport backend: openai-compatible (HTTP endpoint, default), "
|
|
53
|
+
"anthropic (native Messages API), google (native Gemini API), or "
|
|
54
|
+
"claude_code (Claude Agent SDK; chat only). Point --endpoint at a "
|
|
55
|
+
"gateway (e.g. OpenCode Zen) to route a native protocol through it. "
|
|
56
|
+
"complete/FIM is openai-compatible-only.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_headers(items: List[str]) -> dict[str, str]:
|
|
61
|
+
"""Parse repeated ``--header KEY=VALUE`` flags into a dict. Splits on the
|
|
62
|
+
first ``=`` so a value may itself contain ``=``. A missing ``=`` or empty
|
|
63
|
+
name is a usage error. Names are lowercased and both halves stripped (see
|
|
64
|
+
``config._header_item``), so later duplicates win per key regardless of
|
|
65
|
+
case — which is what makes the zstyle→TOML override order reliable."""
|
|
66
|
+
out: dict[str, str] = {}
|
|
67
|
+
for item in items:
|
|
68
|
+
key, sep, val = item.partition("=")
|
|
69
|
+
key, val = _header_item(key, val)
|
|
70
|
+
if not sep or not key:
|
|
71
|
+
raise SystemExit(f"llmkit: --header expects KEY=VALUE, got {item!r}")
|
|
72
|
+
out[key] = val
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _provider_from_args(args: argparse.Namespace) -> Provider:
|
|
77
|
+
"""Map the connection-level flags onto a Provider. enable_thinking is a
|
|
78
|
+
per-call (request) concern, so it's not set here."""
|
|
79
|
+
return Provider(
|
|
80
|
+
model=args.model,
|
|
81
|
+
adapter=args.adapter,
|
|
82
|
+
endpoint=args.endpoint,
|
|
83
|
+
api_key=args.api_key or None,
|
|
84
|
+
api_key_env=args.api_key_env or None,
|
|
85
|
+
max_tokens=args.max_tokens,
|
|
86
|
+
temperature=args.temperature,
|
|
87
|
+
headers=_parse_headers(args.header),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
92
|
+
p = argparse.ArgumentParser(
|
|
93
|
+
prog="llmkit",
|
|
94
|
+
description="Streaming LLM bridge "
|
|
95
|
+
"(openai-compatible / anthropic / google / claude_code).",
|
|
96
|
+
)
|
|
97
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
98
|
+
|
|
99
|
+
chat = sub.add_parser("chat", help="chat-completions endpoint")
|
|
100
|
+
_add_common(chat)
|
|
101
|
+
chat.add_argument("--system", default="")
|
|
102
|
+
chat.add_argument("--user", required=True)
|
|
103
|
+
chat.add_argument(
|
|
104
|
+
"--enable-thinking",
|
|
105
|
+
choices=["auto", "true", "false"],
|
|
106
|
+
default="auto",
|
|
107
|
+
help="maps to chat_template_kwargs.enable_thinking (or the SDK's "
|
|
108
|
+
"thinking config for claude_code)",
|
|
109
|
+
)
|
|
110
|
+
chat.add_argument(
|
|
111
|
+
"--content",
|
|
112
|
+
default="-",
|
|
113
|
+
help="content sink: -, PATH, or none (default: -)",
|
|
114
|
+
)
|
|
115
|
+
chat.add_argument(
|
|
116
|
+
"--thinking",
|
|
117
|
+
default="none",
|
|
118
|
+
help="thinking sink: -, PATH, inline, or none (default: none). "
|
|
119
|
+
"inline merges reasoning into the content stream, separated from "
|
|
120
|
+
"real content by a blank line",
|
|
121
|
+
)
|
|
122
|
+
chat.add_argument(
|
|
123
|
+
"--status-file",
|
|
124
|
+
default="",
|
|
125
|
+
help="path (file or fifo) to receive status lines: 'streaming' on "
|
|
126
|
+
"first chunk, 'complete' on clean exit, 'error' on exception, "
|
|
127
|
+
"'interrupted' on ^C.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
comp = sub.add_parser("complete", help="text-completions endpoint (FIM)")
|
|
131
|
+
_add_common(comp)
|
|
132
|
+
comp.add_argument("--prompt", required=True)
|
|
133
|
+
comp.add_argument("--suffix", default="")
|
|
134
|
+
comp.add_argument(
|
|
135
|
+
"--stop",
|
|
136
|
+
action="append",
|
|
137
|
+
default=[],
|
|
138
|
+
help="stop token (repeatable)",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return p
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
145
|
+
args = build_parser().parse_args(argv)
|
|
146
|
+
provider = _provider_from_args(args)
|
|
147
|
+
if args.cmd == "chat":
|
|
148
|
+
request = bridge.ChatRequest(
|
|
149
|
+
user=args.user,
|
|
150
|
+
system=args.system or None,
|
|
151
|
+
enable_thinking=args.enable_thinking,
|
|
152
|
+
)
|
|
153
|
+
return bridge.chat(
|
|
154
|
+
provider,
|
|
155
|
+
request,
|
|
156
|
+
content=args.content,
|
|
157
|
+
thinking=args.thinking,
|
|
158
|
+
status_file=args.status_file,
|
|
159
|
+
)
|
|
160
|
+
if args.cmd == "complete":
|
|
161
|
+
creq = bridge.CompleteRequest(
|
|
162
|
+
prompt=args.prompt,
|
|
163
|
+
suffix=args.suffix,
|
|
164
|
+
stop=tuple(args.stop),
|
|
165
|
+
)
|
|
166
|
+
return bridge.complete(provider, creq)
|
|
167
|
+
return 2
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
try:
|
|
172
|
+
sys.exit(main())
|
|
173
|
+
except BrokenPipeError:
|
|
174
|
+
sys.exit(0)
|
llmkit/bridge/client.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""OpenAI client construction + small shared helpers.
|
|
2
|
+
|
|
3
|
+
Kept tiny on purpose — the SDK does the heavy lifting; this module just
|
|
4
|
+
resolves the endpoint, API key, and the ``enable_thinking`` extra-body
|
|
5
|
+
the same way for every subcommand. Works off a resolved :class:`Provider`
|
|
6
|
+
(see :func:`llmkit.bridge.bridge.with_defaults`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any, Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from .config import Provider
|
|
19
|
+
|
|
20
|
+
# Dynamic ``${...}`` tokens usable in a header VALUE, generated fresh each time
|
|
21
|
+
# headers are resolved (once per bridge process, i.e. per widget invocation — so
|
|
22
|
+
# ${uuid} is unique per call). Env vars are expanded after these.
|
|
23
|
+
_HEADER_TOKENS: Dict[str, Callable[[], str]] = {
|
|
24
|
+
"uuid": lambda: uuid.uuid4().hex,
|
|
25
|
+
"epoch": lambda: str(int(time.time())),
|
|
26
|
+
}
|
|
27
|
+
_TOKEN_RE = re.compile(r"\$\{(\w+)\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _expand_header_value(value: str) -> str:
|
|
31
|
+
"""Expand a header value: ``${uuid}``/``${epoch}`` dynamic tokens first
|
|
32
|
+
(braces required — ``$uuid`` is NOT a token), then ``$VAR`` / ``${VAR}``
|
|
33
|
+
from the environment (unknown vars left intact). There is no escape for a
|
|
34
|
+
literal ``$``: a value whose ``$…`` happens to name an env var WILL be
|
|
35
|
+
substituted."""
|
|
36
|
+
value = _TOKEN_RE.sub(
|
|
37
|
+
lambda m: _HEADER_TOKENS[m.group(1)]() if m.group(1) in _HEADER_TOKENS else m.group(0),
|
|
38
|
+
value,
|
|
39
|
+
)
|
|
40
|
+
return os.path.expandvars(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolved_headers(provider: Provider) -> Optional[Dict[str, str]]:
|
|
44
|
+
"""The provider's custom headers with each value expanded (see
|
|
45
|
+
:func:`_expand_header_value`), or ``None`` when there are none — the shape the
|
|
46
|
+
SDKs' ``default_headers`` want. Shared by all three HTTP adapters."""
|
|
47
|
+
if not provider.headers:
|
|
48
|
+
return None
|
|
49
|
+
return {k: _expand_header_value(v) for k, v in provider.headers.items()}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_client(provider: Provider) -> Any:
|
|
53
|
+
"""Construct the OpenAI client. Imports inside so ``--help`` works even
|
|
54
|
+
before the dependency is installed. Return type is ``Any`` to avoid a
|
|
55
|
+
hard import of ``openai.OpenAI`` at module load."""
|
|
56
|
+
try:
|
|
57
|
+
from openai import OpenAI
|
|
58
|
+
except ImportError:
|
|
59
|
+
print(
|
|
60
|
+
"llmkit: the openai package is not installed. "
|
|
61
|
+
"Install it with `pip install llmkit[bridge]` (or `pip install "
|
|
62
|
+
"openai`).",
|
|
63
|
+
file=sys.stderr,
|
|
64
|
+
)
|
|
65
|
+
raise SystemExit(2)
|
|
66
|
+
return OpenAI(
|
|
67
|
+
base_url=provider.endpoint,
|
|
68
|
+
api_key=_resolve_api_key(provider),
|
|
69
|
+
default_headers=resolved_headers(provider),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _resolve_api_key(provider: Provider) -> str:
|
|
74
|
+
if provider.api_key_env:
|
|
75
|
+
return os.environ.get(provider.api_key_env, "") or "placeholder"
|
|
76
|
+
if provider.api_key:
|
|
77
|
+
return provider.api_key
|
|
78
|
+
# The SDK requires a non-empty string; local servers don't check it.
|
|
79
|
+
return "placeholder"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def enable_thinking_extra(flag: Optional[str]) -> Dict[str, Any]:
|
|
83
|
+
"""Map ``enable_thinking`` to the ``chat_template_kwargs`` extra body."""
|
|
84
|
+
if flag == "true":
|
|
85
|
+
return {"chat_template_kwargs": {"enable_thinking": True}}
|
|
86
|
+
if flag == "false":
|
|
87
|
+
return {"chat_template_kwargs": {"enable_thinking": False}}
|
|
88
|
+
return {}
|