polyllmkit 0.1.0__tar.gz
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.
- polyllmkit-0.1.0/PKG-INFO +17 -0
- polyllmkit-0.1.0/pyproject.toml +43 -0
- polyllmkit-0.1.0/src/llmkit/__init__.py +20 -0
- polyllmkit-0.1.0/src/llmkit/bridge/__init__.py +52 -0
- polyllmkit-0.1.0/src/llmkit/bridge/__main__.py +11 -0
- polyllmkit-0.1.0/src/llmkit/bridge/adapters/__init__.py +14 -0
- polyllmkit-0.1.0/src/llmkit/bridge/adapters/anthropic.py +154 -0
- polyllmkit-0.1.0/src/llmkit/bridge/adapters/claude_code.py +166 -0
- polyllmkit-0.1.0/src/llmkit/bridge/adapters/google.py +106 -0
- polyllmkit-0.1.0/src/llmkit/bridge/adapters/openai.py +60 -0
- polyllmkit-0.1.0/src/llmkit/bridge/bridge.py +374 -0
- polyllmkit-0.1.0/src/llmkit/bridge/cli.py +174 -0
- polyllmkit-0.1.0/src/llmkit/bridge/client.py +88 -0
- polyllmkit-0.1.0/src/llmkit/bridge/config.py +277 -0
- polyllmkit-0.1.0/src/llmkit/bridge/sinks.py +56 -0
- polyllmkit-0.1.0/src/llmkit/bridge/stream.py +161 -0
- polyllmkit-0.1.0/src/llmkit/md/__init__.py +13 -0
- polyllmkit-0.1.0/src/llmkit/md/render/__init__.py +1 -0
- polyllmkit-0.1.0/src/llmkit/md/render/__main__.py +8 -0
- polyllmkit-0.1.0/src/llmkit/md/render/cli.py +231 -0
- polyllmkit-0.1.0/src/llmkit/md/render/stream.py +244 -0
- polyllmkit-0.1.0/src/llmkit/md/view/__init__.py +1 -0
- polyllmkit-0.1.0/src/llmkit/md/view/__main__.py +8 -0
- polyllmkit-0.1.0/src/llmkit/md/view/app.py +258 -0
- polyllmkit-0.1.0/src/llmkit/md/view/cli.py +74 -0
- polyllmkit-0.1.0/src/llmkit/py.typed +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: polyllmkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps.
|
|
5
|
+
Requires-Dist: anthropic>=0.40 ; extra == 'anthropic'
|
|
6
|
+
Requires-Dist: openai>=1.0 ; extra == 'bridge'
|
|
7
|
+
Requires-Dist: claude-agent-sdk>=0.2 ; extra == 'claude'
|
|
8
|
+
Requires-Dist: google-genai>=1.0 ; extra == 'google'
|
|
9
|
+
Requires-Dist: rich>=13.0 ; extra == 'md'
|
|
10
|
+
Requires-Dist: textual>=0.50 ; extra == 'md'
|
|
11
|
+
Requires-Dist: markdown-it-py>=3.0 ; extra == 'md'
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Provides-Extra: anthropic
|
|
14
|
+
Provides-Extra: bridge
|
|
15
|
+
Provides-Extra: claude
|
|
16
|
+
Provides-Extra: google
|
|
17
|
+
Provides-Extra: md
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Distribution name is `polyllmkit` (the bare `llmkit` PyPI slot is free, but the
|
|
2
|
+
# name is generic/crowded — 63+ GitHub repos share it — so we publish under a
|
|
3
|
+
# distinct slug). The IMPORT name stays `llmkit` (see [tool.uv.build-backend]
|
|
4
|
+
# module-name below): consumers keep `import llmkit`, `pip install polyllmkit`.
|
|
5
|
+
[project]
|
|
6
|
+
name = "polyllmkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Streaming, multi-provider LLM bridge + markdown rendering toolkit for terminal apps."
|
|
9
|
+
requires-python = ">=3.11" # tomllib (bridge.config) is stdlib only from 3.11
|
|
10
|
+
# Base install pulls nothing — pick the subpackage(s) you need via extras.
|
|
11
|
+
# This keeps the two subpackages' dependency graphs disjoint: `md` never
|
|
12
|
+
# drags in openai, `bridge` never drags in rich/textual.
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
# llmkit.bridge — OpenAI-compatible streaming chat/complete bridge.
|
|
17
|
+
bridge = ["openai>=1.0"]
|
|
18
|
+
# Optional native-protocol adapters. Each is imported lazily so the default
|
|
19
|
+
# openai-compatible path never needs them. Point a provider's endpoint at a
|
|
20
|
+
# gateway (e.g. OpenCode Zen) to route the native protocol through it.
|
|
21
|
+
claude = ["claude-agent-sdk>=0.2"] # claude_code adapter (Claude Agent SDK)
|
|
22
|
+
anthropic = ["anthropic>=0.40"] # anthropic adapter (native Messages API)
|
|
23
|
+
google = ["google-genai>=1.0"] # google adapter (native Gemini API)
|
|
24
|
+
# llmkit.md — markdown rendering (one-shot + live stream + scrollable modal).
|
|
25
|
+
# markdown-it-py is a direct import in md.render.stream (textual also pulls
|
|
26
|
+
# it, but we depend on it explicitly since we use it ourselves).
|
|
27
|
+
md = ["rich>=13.0", "textual>=0.50", "markdown-it-py>=3.0"]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.5,<0.12"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.uv.build-backend]
|
|
34
|
+
module-root = "src"
|
|
35
|
+
# Import name != distribution name (like scikit-learn -> sklearn): dist is
|
|
36
|
+
# `polyllmkit`, but the package imports as `llmkit`. Keep this `llmkit`.
|
|
37
|
+
module-name = "llmkit"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"ruff>=0.6",
|
|
42
|
+
"mypy>=1.10",
|
|
43
|
+
]
|
|
@@ -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,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)
|