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 ADDED
@@ -0,0 +1,20 @@
1
+ """llmkit — a streaming LLM bridge + markdown rendering toolkit.
2
+
3
+ Two independent subpackages, disjoint dependency graphs:
4
+
5
+ :mod:`llmkit.bridge` streaming chat/complete over an OpenAI-compatible
6
+ endpoint (or the Claude Agent SDK), with a
7
+ reasoning/content stream splitter, output sinks,
8
+ and a typed provider/profile config parser. Install
9
+ with ``llmkit[bridge]`` (+ ``[claude]`` for the
10
+ claude_code adapter).
11
+ :mod:`llmkit.md` render markdown to a terminal: one-shot, live
12
+ streaming (rich), or a scrollable modal (textual).
13
+ Install with ``llmkit[md]``.
14
+
15
+ Extracted from the zsh-ai plugin; nothing here knows about zsh, zle, or
16
+ any host shell — the surface is plain argv/stdin in, streamed bytes out,
17
+ plus a dataclass API underneath.
18
+ """
19
+
20
+ __version__ = "0.1.0"
@@ -0,0 +1,52 @@
1
+ """llmkit.bridge — streaming chat/complete bridge + typed config.
2
+
3
+ Talks to any OpenAI-compatible HTTP endpoint (via the openai SDK), natively to
4
+ the Anthropic Messages API / Google Gemini API, or the Claude Agent SDK. A
5
+ stream splitter separates reasoning ("thinking") from content so callers can
6
+ route them to different consumers.
7
+
8
+ The reusable surface is the dataclass API: build a :class:`Provider`, then
9
+ call :func:`chat` / :func:`complete`. The flag CLI (``python -m
10
+ llmkit.bridge``) is one adapter over it. Adapter capabilities are queryable —
11
+ :func:`adapter_supports_complete` / :func:`provider_supports_complete` /
12
+ :func:`profile_supports_complete` answer "is FIM available here?" up front, so
13
+ a consumer can disable a FIM widget for chat-only adapters rather than failing
14
+ at call time.
15
+ """
16
+
17
+ from .bridge import (
18
+ ADAPTERS,
19
+ ChatRequest,
20
+ CompleteRequest,
21
+ adapter_supports_complete,
22
+ chat,
23
+ chat_structured,
24
+ chat_to_str,
25
+ complete,
26
+ profile_supports_complete,
27
+ provider_supports_complete,
28
+ with_defaults,
29
+ )
30
+ from .config import Config, ConfigParser, Provider, TomlValue, load
31
+
32
+ __all__ = [
33
+ # config
34
+ "Config",
35
+ "ConfigParser",
36
+ "Provider",
37
+ "TomlValue",
38
+ "load",
39
+ # runtime
40
+ "ChatRequest",
41
+ "CompleteRequest",
42
+ "chat",
43
+ "chat_to_str",
44
+ "chat_structured",
45
+ "complete",
46
+ "with_defaults",
47
+ # adapter capabilities (queryable up front)
48
+ "ADAPTERS",
49
+ "adapter_supports_complete",
50
+ "provider_supports_complete",
51
+ "profile_supports_complete",
52
+ ]
@@ -0,0 +1,11 @@
1
+ """``python -m llmkit.bridge`` → :func:`.cli.main`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ try:
9
+ sys.exit(main())
10
+ except BrokenPipeError:
11
+ sys.exit(0)
@@ -0,0 +1,14 @@
1
+ """Chat producers — one per transport backend.
2
+
3
+ Each exposes a ``stream_*(provider, request, emitter)`` callable matching
4
+ :data:`llmkit.bridge.bridge.Producer`, translating the bridge's generic
5
+ request into that backend's native message/streaming format. Imported lazily
6
+ by :func:`llmkit.bridge.bridge.chat` so the default openai path never pays for
7
+ an optional dependency (anthropic / google-genai / claude-agent-sdk).
8
+
9
+ Backends: ``openai`` (openai-compatible HTTP), ``anthropic`` (native Messages
10
+ API), ``google`` (native Gemini API), ``claude_code`` (Claude Agent SDK). The
11
+ native HTTP adapters take a custom ``endpoint``, so any provider reachable over
12
+ one of these protocols — e.g. an OpenCode Zen gateway — is supported by
13
+ pointing the provider's endpoint at it.
14
+ """
@@ -0,0 +1,154 @@
1
+ """``anthropic`` chat producer — native Anthropic Messages API, streamed.
2
+
3
+ Distinct from ``claude_code`` (which routes through the Claude Agent SDK / the
4
+ ``claude`` CLI's auth): this talks straight to the Messages API via the
5
+ ``anthropic`` Python SDK, so it honours the provider's ``api_key`` /
6
+ ``api_key_env`` and ``max_tokens`` directly.
7
+
8
+ Format differences from the openai-compatible path this copes with:
9
+
10
+ * ``system`` is a top-level request field, not a ``role: system`` message.
11
+ * Reasoning arrives as ``thinking`` content-block deltas, not a
12
+ ``reasoning_content`` field on the chat delta.
13
+ * Sampling params are NOT forwarded — current Anthropic models (Opus 4.8/4.7,
14
+ Sonnet 5, Fable 5) reject ``temperature`` / ``top_p`` with a 400, and govern
15
+ sampling internally. Steer behaviour through the prompt instead.
16
+ * Thinking is configured via the ``thinking`` request param (adaptive only on
17
+ current models — ``budget_tokens`` is removed), not ``chat_template_kwargs``.
18
+ * ``endpoint`` follows the SAME ``/v1``-style convention as the openai adapter
19
+ (e.g. ``https://opencode.ai/zen/v1/messages`` from a gateway's docs, or
20
+ ``http://host/v1``), but the SDK owns the ``/v1/messages`` suffix — it appends
21
+ it to ``base_url``. So the ``/v1...`` tail is stripped here before handing the
22
+ host base to the SDK (else it would double to ``.../v1/v1/messages``). See
23
+ :func:`_base_url`.
24
+
25
+ The SDK is an OPTIONAL dependency, imported here so the default
26
+ openai-compatible path never requires it.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import sys
33
+ from typing import Any, Optional
34
+ from urllib.parse import urlsplit, urlunsplit
35
+
36
+ from ..bridge import DEFAULT_ENDPOINT, ChatRequest, _Emitter
37
+ from ..client import resolved_headers
38
+ from ..config import Provider
39
+
40
+
41
+ def _base_url(endpoint: str) -> str:
42
+ """Derive the anthropic SDK ``base_url`` from a configured endpoint.
43
+
44
+ The SDK appends ``/v1/messages`` to ``base_url``, so a ``/v1``-style
45
+ endpoint — the openai-adapter convention, and what gateway docs list (e.g.
46
+ OpenCode Zen's ``https://opencode.ai/zen/v1/messages``) — must have its
47
+ ``/v1...`` tail dropped or the request URL doubles to ``.../v1/v1/messages``.
48
+ Keep scheme, host, and any path prefix *before* the first ``v1`` path
49
+ segment; an endpoint with no ``v1`` segment (``https://api.anthropic.com``)
50
+ is returned unchanged. ``v1`` in the host (``https://v1.example.com``) is
51
+ untouched — only path segments are considered.
52
+ """
53
+ parts = urlsplit(endpoint)
54
+ segs = parts.path.split("/")
55
+ path = "/".join(segs[: segs.index("v1")]) if "v1" in segs else parts.path.rstrip("/")
56
+ return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
57
+
58
+
59
+ def _require_sdk() -> Any:
60
+ """Import the SDK or exit(2) with an actionable install hint."""
61
+ try:
62
+ import anthropic # type: ignore[import-not-found]
63
+ except ImportError:
64
+ print(
65
+ "llmkit: the anthropic adapter needs the Anthropic SDK. "
66
+ "Install it with `pip install llmkit[anthropic]` (or `pip install "
67
+ "anthropic`).",
68
+ file=sys.stderr,
69
+ )
70
+ raise SystemExit(2)
71
+ return anthropic
72
+
73
+
74
+ def _resolve_api_key(provider: Provider) -> Optional[str]:
75
+ """Resolve an explicit key, or None to let the SDK read its own env
76
+ (``ANTHROPIC_API_KEY`` / an ``ant auth login`` profile)."""
77
+ if provider.api_key_env:
78
+ return os.environ.get(provider.api_key_env) or None
79
+ return provider.api_key or None
80
+
81
+
82
+ def _thinking_config(flag: Optional[str]) -> dict[str, Any]:
83
+ """Map the bridge's auto|true|false onto the Messages API ``thinking`` param.
84
+
85
+ Matches ``claude_code``'s mapping rather than the openai adapter's: the
86
+ native API's only on-mode is adaptive, so both ``auto`` and ``true`` enable
87
+ adaptive thinking — ``auto`` does NOT collapse to off. ``display`` is
88
+ ``summarized`` so reasoning actually streams (current models default it to
89
+ ``omitted`` — empty thinking text). ``false`` disables it.
90
+
91
+ NB: adaptive requires a 4.6+ model — don't pair it with an older one.
92
+ """
93
+ if flag == "false":
94
+ return {"type": "disabled"}
95
+ return {"type": "adaptive", "display": "summarized"}
96
+
97
+
98
+ def stream_anthropic(
99
+ provider: Provider, request: ChatRequest, emitter: _Emitter
100
+ ) -> None:
101
+ anthropic = _require_sdk()
102
+
103
+ kwargs: dict[str, Any] = dict(
104
+ api_key=_resolve_api_key(provider),
105
+ )
106
+ # The bridge fills endpoint with the openai/ollama default; only forward an
107
+ # endpoint the user actually customised (an Anthropic-compatible gateway),
108
+ # normalised to the host base the SDK wants (see _base_url).
109
+ if provider.endpoint and provider.endpoint != DEFAULT_ENDPOINT:
110
+ kwargs["base_url"] = _base_url(provider.endpoint)
111
+ headers = resolved_headers(provider)
112
+ if headers:
113
+ kwargs["default_headers"] = headers
114
+ client = anthropic.Anthropic(**kwargs)
115
+
116
+ create: dict[str, Any] = dict(
117
+ model=provider.model,
118
+ max_tokens=provider.max_tokens,
119
+ messages=[{"role": "user", "content": request.user}],
120
+ )
121
+ if request.system:
122
+ create["system"] = request.system
123
+ # Structured output → a single forced tool whose input_schema IS the caller's
124
+ # schema; the model must fill it. Its ``input_json_delta`` stream is routed to
125
+ # the content sink, so downstream capture (chat_to_str/chat_structured) sees the
126
+ # conformant JSON where free-form text would otherwise be. Forced tool-use and
127
+ # extended thinking are mutually exclusive, so thinking is disabled here.
128
+ structured = request.schema is not None
129
+ if structured:
130
+ create["tools"] = [{
131
+ "name": request.schema_name,
132
+ "description": request.schema_description or "Return the result.",
133
+ "input_schema": request.schema,
134
+ }]
135
+ create["tool_choice"] = {"type": "tool", "name": request.schema_name}
136
+ create["thinking"] = {"type": "disabled"}
137
+ else:
138
+ create["thinking"] = _thinking_config(provider.enable_thinking)
139
+
140
+ with client.messages.stream(**create) as stream:
141
+ for event in stream:
142
+ # Any event is a sign of life — fire TTFT before the first non-empty
143
+ # delta (mirrors the openai adapter's explicit signal).
144
+ emitter.signal_streaming()
145
+ if event.type != "content_block_delta":
146
+ continue
147
+ delta = event.delta
148
+ dt = getattr(delta, "type", None)
149
+ if dt == "text_delta":
150
+ emitter.feed_content(getattr(delta, "text", "") or "")
151
+ elif dt == "input_json_delta": # forced-tool structured output
152
+ emitter.feed_content(getattr(delta, "partial_json", "") or "")
153
+ elif dt == "thinking_delta":
154
+ emitter.feed_reasoning(getattr(delta, "thinking", "") or "")
@@ -0,0 +1,166 @@
1
+ """``claude_code`` chat producer — routes the query through the Claude Agent
2
+ SDK instead of an OpenAI-compatible endpoint.
3
+
4
+ Plain-chat only: every built-in tool is disabled and the agent runs a single
5
+ turn, so it behaves like an ordinary chat completion (no file or shell
6
+ access). Authentication piggybacks on the Claude Code CLI — the user's
7
+ existing ``claude`` login (subscription) or ``ANTHROPIC_API_KEY``, exactly as
8
+ the CLI resolves it. There is no FIM equivalent, so ``complete`` rejects this
9
+ adapter.
10
+
11
+ The SDK is an OPTIONAL dependency, imported here so the default
12
+ openai-compatible path never requires it. The provider's ``endpoint`` /
13
+ ``api_key`` / ``max_tokens`` / ``temperature`` are ignored — the Claude Code
14
+ CLI governs those itself. Only ``model``, the request's ``system``, and
15
+ ``enable_thinking`` carry over.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from typing import Any
23
+
24
+ from ..bridge import ChatRequest, _Emitter
25
+ from ..config import Provider
26
+
27
+
28
+ def _require_sdk() -> Any:
29
+ """Import the SDK or exit(2) with an actionable install hint."""
30
+ try:
31
+ import claude_agent_sdk as sdk # type: ignore[import-not-found]
32
+ except ImportError:
33
+ print(
34
+ "llmkit: the claude_code adapter needs the Claude Agent SDK. "
35
+ "Install it with `pip install llmkit[claude]` (or `pip install "
36
+ "claude-agent-sdk`), and make sure the `claude` CLI is on PATH.",
37
+ file=sys.stderr,
38
+ )
39
+ raise SystemExit(2)
40
+ return sdk
41
+
42
+
43
+ def _thinking_budget() -> int:
44
+ """Token budget for ``enable_thinking = true`` → the SDK's
45
+ ``--max-thinking-tokens``. A CAP on extended thinking, not a floor — the
46
+ model still thinks only as much as the question needs. Decoupled from
47
+ max_tokens (claude_code ignores that for output). Override via
48
+ ``$LLMKIT_CLAUDE_THINKING_BUDGET``; floored at the API minimum 1024."""
49
+ try:
50
+ n = int(os.environ.get("LLMKIT_CLAUDE_THINKING_BUDGET", "") or 0)
51
+ except ValueError:
52
+ n = 0
53
+ return n if n >= 1024 else 8192
54
+
55
+
56
+ def _build_options(sdk: Any, provider: Provider, request: ChatRequest) -> Any:
57
+ """Assemble ClaudeAgentOptions for a tool-free single-turn chat."""
58
+ opts: dict[str, Any] = dict(
59
+ tools=[], # no built-in tools — plain chat, no file/shell access
60
+ allowed_tools=[],
61
+ disallowed_tools=[],
62
+ max_turns=1,
63
+ permission_mode="bypassPermissions",
64
+ include_partial_messages=True, # token-level streaming deltas
65
+ setting_sources=[], # ignore project/user CLAUDE.md, settings, etc.
66
+ )
67
+ if provider.model:
68
+ opts["model"] = provider.model
69
+ if request.system:
70
+ opts["system_prompt"] = request.system
71
+
72
+ # Map the bridge's auto|true|false onto the SDK thinking config:
73
+ # false → disabled (truly off — never "forced on")
74
+ # true → enabled with an explicit budget cap (see _thinking_budget)
75
+ # auto → adaptive: the model scales thinking to the question's difficulty
76
+ # NB: Claude Code returns SUMMARISED thinking (no raw chain-of-thought),
77
+ # so expect it terser than a local reasoning model. It does stream token
78
+ # by token via the partial-message events.
79
+ # Structured output (a JSON Schema request): use the SDK's NATIVE output_format
80
+ # (Messages-API shape); the result lands in ResultMessage.structured_output. Force
81
+ # thinking off — structured output and thinking don't mix (as with forced tool-use).
82
+ if getattr(request, "schema", None):
83
+ opts["output_format"] = {"type": "json_schema", "schema": request.schema}
84
+ opts["thinking"] = {"type": "disabled"}
85
+ return sdk.ClaudeAgentOptions(**opts)
86
+
87
+ flag = provider.enable_thinking or "auto"
88
+ if flag == "false":
89
+ opts["thinking"] = {"type": "disabled"}
90
+ elif flag == "true":
91
+ opts["thinking"] = {"type": "enabled", "budget_tokens": _thinking_budget()}
92
+ else:
93
+ opts["thinking"] = {"type": "adaptive"}
94
+
95
+ return sdk.ClaudeAgentOptions(**opts)
96
+
97
+
98
+ def _handle_event(ev: Any, emitter: _Emitter) -> bool:
99
+ """Translate one raw Anthropic stream event into emitter deltas. Returns
100
+ True if it carried any text/thinking."""
101
+ if not isinstance(ev, dict) or ev.get("type") != "content_block_delta":
102
+ return False
103
+ delta = ev.get("delta") or {}
104
+ dt = delta.get("type")
105
+ if dt == "text_delta":
106
+ emitter.feed_content(delta.get("text", ""))
107
+ return True
108
+ if dt == "thinking_delta":
109
+ emitter.feed_reasoning(delta.get("thinking", ""))
110
+ return True
111
+ return False
112
+
113
+
114
+ def stream_claude_code(
115
+ provider: Provider, request: ChatRequest, emitter: _Emitter
116
+ ) -> None:
117
+ """Synchronous producer entry point — runs the async query to completion,
118
+ raising on any SDK/CLI error for :func:`_run_chat` to report."""
119
+ sdk = _require_sdk()
120
+ import anyio # bundled with the SDK
121
+
122
+ anyio.run(_run, sdk, provider, request, emitter)
123
+
124
+
125
+ async def _run(
126
+ sdk: Any, provider: Provider, request: ChatRequest, emitter: _Emitter
127
+ ) -> None:
128
+ options = _build_options(sdk, provider, request)
129
+ saw_delta = False
130
+ async for message in sdk.query(prompt=request.user, options=options):
131
+ if isinstance(message, sdk.StreamEvent):
132
+ if _handle_event(message.event, emitter):
133
+ saw_delta = True
134
+ elif isinstance(message, sdk.AssistantMessage) and not saw_delta:
135
+ # Fallback for a CLI that doesn't emit partial-message stream
136
+ # events: emit the assembled blocks once, non-incrementally.
137
+ for block in message.content:
138
+ if isinstance(block, sdk.ThinkingBlock):
139
+ emitter.feed_reasoning(getattr(block, "thinking", "") or "")
140
+ elif isinstance(block, sdk.TextBlock):
141
+ emitter.feed_content(getattr(block, "text", "") or "")
142
+
143
+
144
+ def structured_claude_code(
145
+ provider: Provider, request: ChatRequest
146
+ ) -> "tuple[int, Any]":
147
+ """Structured output via the SDK's native ``output_format`` (set in
148
+ ``_build_options`` when the request carries a schema). Runs the single-turn
149
+ query and returns ``(exit_code, structured_output)`` from the ResultMessage —
150
+ the parsed object matching the schema, or ``(0, None)`` if the model produced
151
+ none, or ``(1, None)`` on an SDK-reported error. No text parsing: the CLI does
152
+ the schema enforcement + JSON parse and hands back a Python object."""
153
+ sdk = _require_sdk()
154
+ import anyio # bundled with the SDK
155
+
156
+ out: dict[str, Any] = {"data": None, "error": False}
157
+
158
+ async def _run_structured() -> None:
159
+ options = _build_options(sdk, provider, request)
160
+ async for message in sdk.query(prompt=request.user, options=options):
161
+ if isinstance(message, sdk.ResultMessage):
162
+ out["data"] = getattr(message, "structured_output", None)
163
+ out["error"] = bool(getattr(message, "is_error", False))
164
+
165
+ anyio.run(_run_structured)
166
+ return (1 if out["error"] else 0), out["data"]
@@ -0,0 +1,106 @@
1
+ """``google`` chat producer — native Google Gemini API, streamed, via the
2
+ official ``google-genai`` SDK.
3
+
4
+ Format differences from the openai-compatible path this copes with:
5
+
6
+ * ``system`` is the ``system_instruction`` config field, not a message.
7
+ * The user turn is ``contents``; tuning lives in a ``GenerateContentConfig``.
8
+ * Reasoning ("thought summaries") arrives as content parts flagged
9
+ ``thought=True``, interleaved with answer parts — not a separate field.
10
+ * ``max_tokens`` is ``max_output_tokens``; thinking is a ``thinking_config``.
11
+ Unlike current Anthropic models, Gemini accepts ``temperature``, so it IS
12
+ forwarded here.
13
+
14
+ Point ``endpoint`` at a Gemini-compatible gateway (e.g. an OpenCode Zen base
15
+ URL) to route through it; leave it at the bridge default to hit Google directly.
16
+ The SDK is an OPTIONAL dependency, imported here so the default
17
+ openai-compatible path never requires it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+ from typing import Any, Optional
25
+
26
+ from ..bridge import DEFAULT_ENDPOINT, ChatRequest, _Emitter
27
+ from ..client import resolved_headers
28
+ from ..config import Provider
29
+
30
+
31
+ def _require_sdk() -> Any:
32
+ """Import the SDK or exit(2) with an actionable install hint."""
33
+ try:
34
+ from google import genai # type: ignore[import-untyped,import-not-found]
35
+ from google.genai import types # type: ignore[import-untyped,import-not-found]
36
+ except ImportError:
37
+ print(
38
+ "llmkit: the google adapter needs the Google GenAI SDK. "
39
+ "Install it with `pip install llmkit[google]` (or `pip install "
40
+ "google-genai`).",
41
+ file=sys.stderr,
42
+ )
43
+ raise SystemExit(2)
44
+ return genai, types
45
+
46
+
47
+ def _resolve_api_key(provider: Provider) -> Optional[str]:
48
+ """Resolve an explicit key, or None to let the SDK read its own env
49
+ (``GOOGLE_API_KEY`` / ``GEMINI_API_KEY``)."""
50
+ if provider.api_key_env:
51
+ return os.environ.get(provider.api_key_env) or None
52
+ return provider.api_key or None
53
+
54
+
55
+ def _thinking_config(types: Any, flag: Optional[str]) -> Optional[Any]:
56
+ """Map the bridge's auto|true|false onto a ``ThinkingConfig``.
57
+
58
+ ``auto`` omits it (model default), matching the openai adapter. ``true``
59
+ requests dynamic thinking (``thinking_budget=-1``) with thought summaries so
60
+ reasoning streams. ``false`` disables it (``thinking_budget=0`` — honoured by
61
+ Flash/Flash-Lite; Pro clamps to its minimum)."""
62
+ if flag == "false":
63
+ return types.ThinkingConfig(thinking_budget=0)
64
+ if flag == "true":
65
+ return types.ThinkingConfig(include_thoughts=True, thinking_budget=-1)
66
+ return None # auto → let the model decide
67
+
68
+
69
+ def stream_google(provider: Provider, request: ChatRequest, emitter: _Emitter) -> None:
70
+ genai, types = _require_sdk()
71
+
72
+ client_kwargs: dict[str, Any] = dict(api_key=_resolve_api_key(provider))
73
+ # The bridge fills endpoint with the openai/ollama default; only forward an
74
+ # endpoint the user actually customised (a Gemini-compatible gateway).
75
+ # Custom headers (e.g. a gateway routing key) ride on the same HttpOptions.
76
+ http_opts: dict[str, Any] = {}
77
+ if provider.endpoint and provider.endpoint != DEFAULT_ENDPOINT:
78
+ http_opts["base_url"] = provider.endpoint
79
+ headers = resolved_headers(provider)
80
+ if headers:
81
+ http_opts["headers"] = headers
82
+ if http_opts:
83
+ client_kwargs["http_options"] = types.HttpOptions(**http_opts)
84
+ client = genai.Client(**client_kwargs)
85
+
86
+ config = types.GenerateContentConfig(
87
+ system_instruction=request.system or None,
88
+ max_output_tokens=provider.max_tokens,
89
+ temperature=provider.temperature,
90
+ thinking_config=_thinking_config(types, provider.enable_thinking),
91
+ )
92
+
93
+ for chunk in client.models.generate_content_stream(
94
+ model=provider.model, contents=request.user, config=config
95
+ ):
96
+ emitter.signal_streaming()
97
+ for candidate in getattr(chunk, "candidates", None) or []:
98
+ content = getattr(candidate, "content", None)
99
+ for part in getattr(content, "parts", None) or []:
100
+ text = getattr(part, "text", None) or ""
101
+ if not text:
102
+ continue
103
+ if getattr(part, "thought", False):
104
+ emitter.feed_reasoning(text)
105
+ else:
106
+ emitter.feed_content(text)
@@ -0,0 +1,60 @@
1
+ """Default chat producer: OpenAI-compatible chat-completions, streamed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..client import build_client, enable_thinking_extra
8
+ from ..config import Provider
9
+
10
+ # Imported under TYPE_CHECKING-free runtime: bridge imports this lazily, so a
11
+ # direct import here is fine and avoids a cycle (bridge → adapters, not back).
12
+ from ..bridge import ChatRequest, _Emitter
13
+
14
+
15
+ def stream_openai(provider: Provider, request: ChatRequest, emitter: _Emitter) -> None:
16
+ client = build_client(provider)
17
+
18
+ messages = []
19
+ if request.system:
20
+ messages.append({"role": "system", "content": request.system})
21
+ messages.append({"role": "user", "content": request.user})
22
+
23
+ # Any-typed so the conditional appends + the **kwargs unpack into openai's
24
+ # heavily-overloaded create() don't trigger a wall of variance complaints.
25
+ kwargs: dict[str, Any] = dict(
26
+ model=provider.model,
27
+ messages=messages,
28
+ max_tokens=provider.max_tokens,
29
+ temperature=provider.temperature,
30
+ )
31
+ extra = enable_thinking_extra(provider.enable_thinking)
32
+ if extra:
33
+ kwargs["extra_body"] = extra
34
+ # Structured output → constrain decoding to the caller's JSON Schema; the
35
+ # filled JSON streams as ordinary content (feed_content below), so capture is
36
+ # unchanged. Thinking is dropped when structured (mirrors the anthropic path).
37
+ if request.schema is not None:
38
+ kwargs["response_format"] = {
39
+ "type": "json_schema",
40
+ "json_schema": {"name": request.schema_name, "schema": request.schema},
41
+ }
42
+ kwargs.pop("extra_body", None)
43
+ kwargs["stream"] = True
44
+
45
+ for chunk in client.chat.completions.create(**kwargs):
46
+ if not chunk.choices:
47
+ continue
48
+ # Fire "streaming" on the FIRST chunk that has choices — even a
49
+ # role-only/empty delta: TTFT mitigation cares about *any* sign of
50
+ # life. feed_content / feed_reasoning ALSO signal, but relying on
51
+ # them alone would defer this to the first NON-empty delta. So this
52
+ # explicit call is load-bearing, not redundant.
53
+ emitter.signal_streaming()
54
+ delta = chunk.choices[0].delta
55
+ r = getattr(delta, "reasoning_content", None) or ""
56
+ c = getattr(delta, "content", None) or ""
57
+ if r:
58
+ emitter.feed_reasoning(r)
59
+ if c:
60
+ emitter.feed_content(c)