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,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)
@@ -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 {}