thinchat 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.
- thinchat/__init__.py +51 -0
- thinchat/claude_client.py +139 -0
- thinchat/client.py +225 -0
- thinchat/errors.py +45 -0
- thinchat/keys.py +30 -0
- thinchat/openai_client.py +283 -0
- thinchat/providers.py +68 -0
- thinchat/py.typed +0 -0
- thinchat/structured_output.py +65 -0
- thinchat-0.1.0.dist-info/METADATA +169 -0
- thinchat-0.1.0.dist-info/RECORD +13 -0
- thinchat-0.1.0.dist-info/WHEEL +4 -0
- thinchat-0.1.0.dist-info/licenses/LICENSE +21 -0
thinchat/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""thinchat: a tiny, unified client for four LLM providers -- claude, openai, gemini, ollama.
|
|
2
|
+
|
|
3
|
+
Name a provider, then call it. Each client offers completion (whole, streamed, or
|
|
4
|
+
JSON-structured) and, where the provider has one, embeddings -- with an async twin for each.
|
|
5
|
+
|
|
6
|
+
from thinchat import make_client
|
|
7
|
+
|
|
8
|
+
llm = make_client("claude") # key from CLAUDE_API_KEY
|
|
9
|
+
print(llm.complete("Say hi in one word."))
|
|
10
|
+
verdict = llm.parse("Is this an ad? 'Buy now, 50% off'",
|
|
11
|
+
schema={"type": "object",
|
|
12
|
+
"properties": {"is_ad": {"type": "boolean"}},
|
|
13
|
+
"required": ["is_ad"]})
|
|
14
|
+
|
|
15
|
+
Installing thinchat brings both provider SDKs (openai and anthropic), so every provider
|
|
16
|
+
works out of the box; each SDK is imported lazily when you first construct its client.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
20
|
+
|
|
21
|
+
from thinchat.claude_client import ClaudeClient
|
|
22
|
+
from thinchat.client import Capability, Client, Provider
|
|
23
|
+
from thinchat.errors import (
|
|
24
|
+
LLMError,
|
|
25
|
+
ProviderUnavailableError,
|
|
26
|
+
ThinchatError,
|
|
27
|
+
UnknownProviderError,
|
|
28
|
+
UnsupportedError,
|
|
29
|
+
)
|
|
30
|
+
from thinchat.openai_client import OpenAICompatibleClient
|
|
31
|
+
from thinchat.providers import PROVIDERS, make_client
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"PROVIDERS",
|
|
35
|
+
"Capability",
|
|
36
|
+
"ClaudeClient",
|
|
37
|
+
"Client",
|
|
38
|
+
"LLMError",
|
|
39
|
+
"OpenAICompatibleClient",
|
|
40
|
+
"Provider",
|
|
41
|
+
"ProviderUnavailableError",
|
|
42
|
+
"ThinchatError",
|
|
43
|
+
"UnknownProviderError",
|
|
44
|
+
"UnsupportedError",
|
|
45
|
+
"make_client",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
__version__ = version("thinchat") # single source of truth: the installed metadata
|
|
50
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
51
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The Claude client, over Anthropic's native SDK.
|
|
2
|
+
|
|
3
|
+
Claude is the one client not reached through the OpenAI-compatible path: Anthropic's API
|
|
4
|
+
takes ``system`` as its own argument rather than a message, and Anthropic has no
|
|
5
|
+
first-party embeddings API, so this client has completion, streaming, and structured
|
|
6
|
+
output but refuses ``embed`` (the base's default). ``max_tokens`` is required by this API
|
|
7
|
+
(unlike OpenAI's), so it is a constructor knob with a generous default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import AsyncIterator, Iterator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import thinchat.keys as keys
|
|
16
|
+
from thinchat.client import Capability, _BaseClient
|
|
17
|
+
from thinchat.errors import LLMError, ProviderUnavailableError
|
|
18
|
+
|
|
19
|
+
__all__ = ["ClaudeClient"]
|
|
20
|
+
|
|
21
|
+
_CLAUDE_DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
|
22
|
+
|
|
23
|
+
# No "embeddings": Anthropic has no first-party embeddings API, so embed stays the base's
|
|
24
|
+
# refusing default and a caller reads `supports("embeddings")` as False.
|
|
25
|
+
_CAPABILITIES: frozenset[Capability] = frozenset({"completion", "streaming", "structured_output"})
|
|
26
|
+
|
|
27
|
+
# Anthropic requires max_tokens (unlike OpenAI, where it is optional). 4096 is a generous
|
|
28
|
+
# default that stays within every current Claude model's output limit; a caller wanting a
|
|
29
|
+
# different cap passes max_tokens= to make_client.
|
|
30
|
+
_CLAUDE_DEFAULT_MAX_TOKENS = 4096
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeClient(_BaseClient):
|
|
34
|
+
"""A client over Anthropic's ``anthropic`` SDK. Holds a sync client and lazily builds a
|
|
35
|
+
reusable async client on first async use, plus the model id. ``max_tokens`` bounds every
|
|
36
|
+
reply (the API requires it)."""
|
|
37
|
+
|
|
38
|
+
_sdk_error: type[Exception]
|
|
39
|
+
_stream_errors: tuple[type[Exception], ...]
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, api_key: str, model: str, max_tokens: int = _CLAUDE_DEFAULT_MAX_TOKENS) -> None:
|
|
42
|
+
try:
|
|
43
|
+
import httpx
|
|
44
|
+
from anthropic import Anthropic, AnthropicError
|
|
45
|
+
except ImportError as err:
|
|
46
|
+
raise ProviderUnavailableError(
|
|
47
|
+
"the anthropic package is required but could not be imported; reinstall thinchat"
|
|
48
|
+
) from err
|
|
49
|
+
self.model = model
|
|
50
|
+
self.capabilities = _CAPABILITIES
|
|
51
|
+
self._api_key = api_key
|
|
52
|
+
self._max_tokens = max_tokens
|
|
53
|
+
# Catch only Anthropic's own error family (so our bugs surface as themselves); the
|
|
54
|
+
# streaming path also raises raw httpx on a mid-stream transport drop.
|
|
55
|
+
self._sdk_error = AnthropicError
|
|
56
|
+
self._stream_errors = (AnthropicError, httpx.HTTPError)
|
|
57
|
+
self._client = Anthropic(api_key=api_key)
|
|
58
|
+
self._aclient = None # built on first async use (see _make_aclient)
|
|
59
|
+
|
|
60
|
+
def _make_aclient(self) -> Any:
|
|
61
|
+
from anthropic import AsyncAnthropic # the sync import above already proved it installed
|
|
62
|
+
return AsyncAnthropic(api_key=self._api_key)
|
|
63
|
+
|
|
64
|
+
def complete(self, prompt: str, *, system: str | None = None) -> str:
|
|
65
|
+
try:
|
|
66
|
+
response = self._client.messages.create(**self._make_request(prompt, system))
|
|
67
|
+
except self._sdk_error as err:
|
|
68
|
+
raise LLMError(f"claude completion failed: {err}") from err
|
|
69
|
+
return _extract_message_text(response)
|
|
70
|
+
|
|
71
|
+
async def acomplete(self, prompt: str, *, system: str | None = None) -> str:
|
|
72
|
+
try:
|
|
73
|
+
response = await self._get_aclient().messages.create(**self._make_request(prompt, system))
|
|
74
|
+
except self._sdk_error as err:
|
|
75
|
+
raise LLMError(f"claude completion failed: {err}") from err
|
|
76
|
+
return _extract_message_text(response)
|
|
77
|
+
|
|
78
|
+
def stream(self, prompt: str, *, system: str | None = None) -> Iterator[str]:
|
|
79
|
+
try:
|
|
80
|
+
with self._client.messages.stream(**self._make_request(prompt, system)) as events:
|
|
81
|
+
yield from events.text_stream
|
|
82
|
+
except self._stream_errors as err:
|
|
83
|
+
raise LLMError(f"claude stream failed: {err}") from err
|
|
84
|
+
|
|
85
|
+
async def astream(self, prompt: str, *, system: str | None = None) -> AsyncIterator[str]:
|
|
86
|
+
try:
|
|
87
|
+
async with self._get_aclient().messages.stream(**self._make_request(prompt, system)) as events:
|
|
88
|
+
async for chunk in events.text_stream:
|
|
89
|
+
yield chunk
|
|
90
|
+
except self._stream_errors as err:
|
|
91
|
+
raise LLMError(f"claude stream failed: {err}") from err
|
|
92
|
+
|
|
93
|
+
def _make_request(self, prompt: str, system: str | None) -> dict[str, object]:
|
|
94
|
+
request: dict[str, object] = {
|
|
95
|
+
"model": self.model,
|
|
96
|
+
"max_tokens": self._max_tokens,
|
|
97
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
98
|
+
}
|
|
99
|
+
if system is not None:
|
|
100
|
+
request["system"] = system # Anthropic takes system as its own field, not a message
|
|
101
|
+
return request
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _make_claude_client(
|
|
105
|
+
*, model: str | None = None, api_key: str | None = None, max_tokens: int | None = None,
|
|
106
|
+
) -> ClaudeClient:
|
|
107
|
+
"""Construct the Claude client. ``max_tokens`` caps the reply; when None the default
|
|
108
|
+
(Anthropic requires the field) is used.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
ProviderUnavailableError: the anthropic SDK is not installed, or no API key is set
|
|
112
|
+
(or passed).
|
|
113
|
+
"""
|
|
114
|
+
key = api_key if api_key is not None else keys.get_api_key("claude")
|
|
115
|
+
if not key:
|
|
116
|
+
raise ProviderUnavailableError(
|
|
117
|
+
f"no API key for claude: set {keys.ENV_BY_PROVIDER['claude']} or pass api_key="
|
|
118
|
+
)
|
|
119
|
+
return ClaudeClient(
|
|
120
|
+
api_key = key,
|
|
121
|
+
model = model or _CLAUDE_DEFAULT_MODEL,
|
|
122
|
+
max_tokens = max_tokens if max_tokens is not None else _CLAUDE_DEFAULT_MAX_TOKENS,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _extract_message_text(response: object) -> str:
|
|
127
|
+
"""The concatenated text blocks of a Messages response. Raises ``LLMError`` when the
|
|
128
|
+
reply is empty, or carries content but no text block (e.g. a non-text block only)."""
|
|
129
|
+
content = getattr(response, "content", None)
|
|
130
|
+
if not isinstance(content, list) or not content:
|
|
131
|
+
raise LLMError("claude completion returned an empty reply")
|
|
132
|
+
parts = [
|
|
133
|
+
block.text for block in content
|
|
134
|
+
if getattr(block, "type", None) == "text" and isinstance(getattr(block, "text", None), str)
|
|
135
|
+
]
|
|
136
|
+
text = "".join(parts).strip()
|
|
137
|
+
if not text:
|
|
138
|
+
raise LLMError("claude reply carried no text block")
|
|
139
|
+
return text
|
thinchat/client.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""The client contract and the behaviour every client shares.
|
|
2
|
+
|
|
3
|
+
``Client`` is the structural type a caller depends on -- four verbs, each with an async
|
|
4
|
+
twin, plus a capability probe. ``_BaseClient`` supplies the parts that do not vary by
|
|
5
|
+
provider: structured output steered through the prompt (overridden by a client with native
|
|
6
|
+
support), embeddings that refuse by default (Claude has no embeddings API), the ``supports``
|
|
7
|
+
probe, and closing the underlying HTTP pools. A concrete client implements only the four
|
|
8
|
+
primitives -- ``complete``, ``acomplete``, ``stream``, ``astream`` -- and declares its
|
|
9
|
+
``capabilities``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
17
|
+
from typing import Any, Literal, Protocol, Self, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from thinchat.errors import UnsupportedError
|
|
20
|
+
from thinchat.structured_output import make_json_instruction, parse_json
|
|
21
|
+
|
|
22
|
+
__all__ = ["Capability", "Client", "Provider"]
|
|
23
|
+
|
|
24
|
+
# The four providers thinchat speaks to, as a closed type. The public boundary
|
|
25
|
+
# (``make_client``) still takes a runtime ``str`` (a config value is not a Literal) and
|
|
26
|
+
# validates it; the internal surfaces carry ``Provider`` so a typo is a static error.
|
|
27
|
+
Provider = Literal["claude", "openai", "gemini", "ollama"]
|
|
28
|
+
|
|
29
|
+
# What a client can do. A caller reads ``supports`` (or catches ``UnsupportedError``)
|
|
30
|
+
# rather than assuming: Claude, for one, has completion and streaming but no embeddings.
|
|
31
|
+
Capability = Literal["completion", "streaming", "structured_output", "embeddings"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class Client(Protocol):
|
|
36
|
+
"""One LLM client. ``model`` is the chat model it calls; the verbs turn a prompt into
|
|
37
|
+
a completion (whole, streamed, or JSON) and texts into embeddings. Each has an async
|
|
38
|
+
twin for a caller inside an event loop.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
LLMError: a failed API call, or an empty / malformed reply -- for the
|
|
42
|
+
non-streaming verbs (complete/acomplete/parse/aparse/embed/aembed). For
|
|
43
|
+
stream/astream the error surfaces *while iterating*, not at the call, and an
|
|
44
|
+
empty stream is not itself an error.
|
|
45
|
+
UnsupportedError: embed / aembed on a provider with no embeddings API (Claude);
|
|
46
|
+
check ``supports("embeddings")`` first."""
|
|
47
|
+
|
|
48
|
+
model: str
|
|
49
|
+
capabilities: frozenset[Capability]
|
|
50
|
+
|
|
51
|
+
def supports(self, capability: Capability) -> bool: ...
|
|
52
|
+
|
|
53
|
+
def complete(self, prompt: str, *, system: str | None = None) -> str: ...
|
|
54
|
+
async def acomplete(self, prompt: str, *, system: str | None = None) -> str: ...
|
|
55
|
+
|
|
56
|
+
def stream(self, prompt: str, *, system: str | None = None) -> Iterator[str]: ...
|
|
57
|
+
def astream(self, prompt: str, *, system: str | None = None) -> AsyncIterator[str]: ...
|
|
58
|
+
|
|
59
|
+
def parse(
|
|
60
|
+
self, prompt: str, schema: dict[str, object], *, system: str | None = None
|
|
61
|
+
) -> dict[str, object]: ...
|
|
62
|
+
async def aparse(
|
|
63
|
+
self, prompt: str, schema: dict[str, object], *, system: str | None = None
|
|
64
|
+
) -> dict[str, object]: ...
|
|
65
|
+
|
|
66
|
+
def embed(self, texts: Sequence[str], *, model: str | None = None) -> list[list[float]]: ...
|
|
67
|
+
async def aembed(
|
|
68
|
+
self, texts: Sequence[str], *, model: str | None = None
|
|
69
|
+
) -> list[list[float]]: ...
|
|
70
|
+
|
|
71
|
+
def close(self) -> None: ...
|
|
72
|
+
async def aclose(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> Self: ...
|
|
75
|
+
def __exit__(self, *exc: object) -> None: ...
|
|
76
|
+
async def __aenter__(self) -> Self: ...
|
|
77
|
+
async def __aexit__(self, *exc: object) -> None: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _BaseClient(ABC):
|
|
81
|
+
"""Shared behaviour behind the concrete clients. Subclasses implement the four
|
|
82
|
+
primitives and ``_make_aclient`` (which builds the vendor's async SDK client), set
|
|
83
|
+
``model`` / ``capabilities``, assign ``_client`` (the sync SDK client), and set
|
|
84
|
+
``_aclient`` to None (the async client is built lazily on first async use); everything
|
|
85
|
+
else is derived here."""
|
|
86
|
+
|
|
87
|
+
model: str
|
|
88
|
+
capabilities: frozenset[Capability]
|
|
89
|
+
_client: Any # the vendor's sync SDK client (owns an HTTP connection pool)
|
|
90
|
+
_aclient: Any # the vendor's async SDK client, or None until first async use
|
|
91
|
+
|
|
92
|
+
def supports(self, capability: Capability) -> bool:
|
|
93
|
+
"""Whether this client offers ``capability`` -- the check to make before calling
|
|
94
|
+
a verb that might raise ``UnsupportedError`` (e.g. ``embed`` on Claude)."""
|
|
95
|
+
return capability in self.capabilities
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"{type(self).__name__}(model={self.model!r})"
|
|
99
|
+
|
|
100
|
+
# --- primitives each client implements natively -------------------------------
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def complete(self, prompt: str, *, system: str | None = None) -> str: ...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
async def acomplete(self, prompt: str, *, system: str | None = None) -> str: ...
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def stream(self, prompt: str, *, system: str | None = None) -> Iterator[str]: ...
|
|
110
|
+
|
|
111
|
+
@abstractmethod
|
|
112
|
+
def astream(self, prompt: str, *, system: str | None = None) -> AsyncIterator[str]: ...
|
|
113
|
+
|
|
114
|
+
# --- async SDK client: built on first async use --------------------------------
|
|
115
|
+
# The async verbs go through _get_aclient() rather than touching _aclient, so a
|
|
116
|
+
# sync-only caller never constructs an async pool it then cannot release (close() and
|
|
117
|
+
# __exit__ are sync and reach only _client). A subclass makes its vendor async client
|
|
118
|
+
# in _make_aclient; this caches it after the first async call. (Not synchronised: two
|
|
119
|
+
# threads first-touching an async verb at once could build two -- fine for a client
|
|
120
|
+
# driven from a single event loop, the normal case.)
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def _make_aclient(self) -> Any: ...
|
|
124
|
+
|
|
125
|
+
def _get_aclient(self) -> Any:
|
|
126
|
+
if self._aclient is None:
|
|
127
|
+
self._aclient = self._make_aclient()
|
|
128
|
+
return self._aclient
|
|
129
|
+
|
|
130
|
+
# --- structured output: default steers through the prompt ----------------------
|
|
131
|
+
# A client with native constrained output (OpenAI's response_format) overrides the
|
|
132
|
+
# ``_text_for_parse`` hooks; the default appends the schema to the system prompt and
|
|
133
|
+
# parses the reply, which works on any client that can follow an instruction.
|
|
134
|
+
|
|
135
|
+
def parse(
|
|
136
|
+
self, prompt: str, schema: dict[str, object], *, system: str | None = None
|
|
137
|
+
) -> dict[str, object]:
|
|
138
|
+
"""Return the reply parsed into a JSON object. ``schema`` is supplied to guide (or
|
|
139
|
+
natively constrain) generation; it is not validated locally, so a caller that needs
|
|
140
|
+
strict conformance checks the returned object itself. Raises ``LLMError`` if the
|
|
141
|
+
reply is not a JSON object."""
|
|
142
|
+
return parse_json(self._text_for_parse(prompt, _with_schema(system, schema)))
|
|
143
|
+
|
|
144
|
+
async def aparse(
|
|
145
|
+
self, prompt: str, schema: dict[str, object], *, system: str | None = None
|
|
146
|
+
) -> dict[str, object]:
|
|
147
|
+
"""Async twin of ``parse``."""
|
|
148
|
+
return parse_json(await self._atext_for_parse(prompt, _with_schema(system, schema)))
|
|
149
|
+
|
|
150
|
+
def _text_for_parse(self, prompt: str, system: str) -> str:
|
|
151
|
+
"""How ``parse`` gets its raw text. The default just completes with the schema
|
|
152
|
+
folded into the system prompt; a client with native JSON mode overrides this to
|
|
153
|
+
constrain the reply at the API as well."""
|
|
154
|
+
return self.complete(prompt, system=system)
|
|
155
|
+
|
|
156
|
+
async def _atext_for_parse(self, prompt: str, system: str) -> str:
|
|
157
|
+
"""Async twin of ``_text_for_parse``."""
|
|
158
|
+
return await self.acomplete(prompt, system=system)
|
|
159
|
+
|
|
160
|
+
# --- embeddings: refused unless a client overrides ----------------------------
|
|
161
|
+
|
|
162
|
+
def embed(self, texts: Sequence[str], *, model: str | None = None) -> list[list[float]]:
|
|
163
|
+
"""Return one embedding vector per input text. Raises ``UnsupportedError`` on a
|
|
164
|
+
client without an embeddings API (Claude)."""
|
|
165
|
+
raise UnsupportedError(f"{type(self).__name__} does not support embeddings")
|
|
166
|
+
|
|
167
|
+
async def aembed(
|
|
168
|
+
self, texts: Sequence[str], *, model: str | None = None
|
|
169
|
+
) -> list[list[float]]:
|
|
170
|
+
"""Async twin of ``embed``."""
|
|
171
|
+
raise UnsupportedError(f"{type(self).__name__} does not support embeddings")
|
|
172
|
+
|
|
173
|
+
# --- lifecycle: release the underlying HTTP connection pools -------------------
|
|
174
|
+
|
|
175
|
+
def close(self) -> None:
|
|
176
|
+
"""Close the sync SDK client's connection pool. Use ``with make_client(...) as c:``
|
|
177
|
+
or call this when done, so a server building a client per request does not leak
|
|
178
|
+
connections. If you drove async verbs (``acomplete`` / ``astream`` / ...), release
|
|
179
|
+
with ``aclose()`` or ``async with`` instead -- that also closes the async pool,
|
|
180
|
+
which this sync close cannot."""
|
|
181
|
+
_close(self._client)
|
|
182
|
+
|
|
183
|
+
async def aclose(self) -> None:
|
|
184
|
+
"""Close both the async and sync SDK clients' pools. The async pool exists only if
|
|
185
|
+
an async verb was used (it is built lazily); the sync pool is always released, even
|
|
186
|
+
if closing the async one raises."""
|
|
187
|
+
try:
|
|
188
|
+
aclient = self._aclient
|
|
189
|
+
if aclient is not None:
|
|
190
|
+
# The async SDK client's close is a coroutine named ``close``
|
|
191
|
+
# (openai/anthropic), occasionally ``aclose`` elsewhere; accept either and
|
|
192
|
+
# await it if it is one.
|
|
193
|
+
aclose = getattr(aclient, "aclose", None) or getattr(aclient, "close", None)
|
|
194
|
+
if callable(aclose):
|
|
195
|
+
result = aclose()
|
|
196
|
+
if inspect.isawaitable(result):
|
|
197
|
+
await result
|
|
198
|
+
finally:
|
|
199
|
+
_close(self._client)
|
|
200
|
+
|
|
201
|
+
def __enter__(self) -> Self:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
def __exit__(self, *exc: object) -> None:
|
|
205
|
+
self.close()
|
|
206
|
+
|
|
207
|
+
async def __aenter__(self) -> Self:
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
211
|
+
await self.aclose()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _with_schema(system: str | None, schema: dict[str, object]) -> str:
|
|
215
|
+
"""Fold the JSON-schema instruction into the system prompt: appended when a system
|
|
216
|
+
prompt is given, else it stands alone as the system prompt."""
|
|
217
|
+
instruction = make_json_instruction(schema)
|
|
218
|
+
return f"{system}\n\n{instruction}" if system else instruction
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _close(client: object) -> None:
|
|
222
|
+
"""Call an SDK client's ``close`` if it has one (the fakes and some SDKs do not)."""
|
|
223
|
+
close = getattr(client, "close", None)
|
|
224
|
+
if callable(close):
|
|
225
|
+
close()
|
thinchat/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Domain exception hierarchy for thinchat.
|
|
2
|
+
|
|
3
|
+
Every error thinchat raises on purpose derives from ``ThinchatError``, so a caller can
|
|
4
|
+
handle this package's failures with one ``except`` without catching unrelated bugs. The
|
|
5
|
+
tree separates the four ways a call can fail: the client could not be built
|
|
6
|
+
(``ProviderUnavailableError``), the name is not one we know (``UnknownProviderError``), the
|
|
7
|
+
client does not offer the requested capability (``UnsupportedError``), or the API call
|
|
8
|
+
itself failed (``LLMError``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ThinchatError",
|
|
15
|
+
"ProviderUnavailableError",
|
|
16
|
+
"LLMError",
|
|
17
|
+
"UnknownProviderError",
|
|
18
|
+
"UnsupportedError",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ThinchatError(Exception):
|
|
23
|
+
"""Base for every error thinchat raises deliberately."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnknownProviderError(ThinchatError):
|
|
27
|
+
"""The requested client name is not one of the four thinchat supports. Permanent --
|
|
28
|
+
a typo or an unsupported provider -- so the caller fixes the name rather than retries."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderUnavailableError(ThinchatError):
|
|
32
|
+
"""A client could not be constructed: its SDK is not installed (install the matching
|
|
33
|
+
extra) or no API key is available for it. Distinct from ``LLMError`` because nothing
|
|
34
|
+
was sent -- the failure is local setup, not the remote service."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnsupportedError(ThinchatError):
|
|
38
|
+
"""The client does not offer the requested capability -- asking Claude for embeddings,
|
|
39
|
+
which Anthropic has no first-party API for. Permanent for that client, so ``supports``
|
|
40
|
+
lets a caller check before calling."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LLMError(ThinchatError):
|
|
44
|
+
"""The API call was made but failed: the service returned an error, or the reply was
|
|
45
|
+
empty or not the requested shape. Its message carries the underlying cause."""
|
thinchat/keys.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Resolve a client's API key from the environment.
|
|
2
|
+
|
|
3
|
+
thinchat is a library, not an application, so it reads keys from the environment (or an
|
|
4
|
+
explicit ``api_key=`` a caller passes) and never imposes a file location of its own --
|
|
5
|
+
where keys live on disk is the calling program's decision. Each name follows the provider
|
|
6
|
+
handle you pass to ``make_client`` -- ``<PROVIDER>_API_KEY`` -- so ``make_client("claude")``
|
|
7
|
+
pairs with ``CLAUDE_API_KEY``, matching the roster rather than each vendor SDK's own default.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
__all__ = ["ENV_BY_PROVIDER", "get_api_key"]
|
|
15
|
+
|
|
16
|
+
# The environment variable each provider's key is read from. ollama is absent on purpose:
|
|
17
|
+
# a local server needs no key, so its client passes a dummy the SDK accepts.
|
|
18
|
+
ENV_BY_PROVIDER: dict[str, str] = {
|
|
19
|
+
"openai": "OPENAI_API_KEY",
|
|
20
|
+
"gemini": "GEMINI_API_KEY",
|
|
21
|
+
"claude": "CLAUDE_API_KEY",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_api_key(provider: str) -> str | None:
|
|
26
|
+
"""Return the API key for ``provider`` from its environment variable, or ``None`` when
|
|
27
|
+
it is unset (so a client can phrase its own "no key" error) or the provider needs
|
|
28
|
+
none (ollama). A ``provider`` outside the roster simply reads as "no env"."""
|
|
29
|
+
name = ENV_BY_PROVIDER.get(provider)
|
|
30
|
+
return os.environ.get(name) if name is not None else None
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The three clients that speak the OpenAI-compatible API: openai, gemini, ollama.
|
|
2
|
+
|
|
3
|
+
OpenAI, Gemini, and Ollama all expose the same ``/chat/completions`` and ``/embeddings``
|
|
4
|
+
shapes, so one client over the ``openai`` SDK serves all three -- they differ only in
|
|
5
|
+
data: the base URL, the key, the default models, and whether the endpoint honours a
|
|
6
|
+
native JSON mode. Gemini and Ollama are reached by pointing the SDK's ``base_url`` at
|
|
7
|
+
their compatible endpoints; only the provider rows below change.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import thinchat.keys as keys
|
|
18
|
+
from thinchat.client import Capability, _BaseClient
|
|
19
|
+
from thinchat.errors import LLMError, ProviderUnavailableError, UnknownProviderError
|
|
20
|
+
|
|
21
|
+
__all__ = ["OpenAICompatibleClient"]
|
|
22
|
+
|
|
23
|
+
# An OpenAI-compatible client does everything; only Claude drops a capability.
|
|
24
|
+
_CAPABILITIES: frozenset[Capability] = frozenset(
|
|
25
|
+
{"completion", "streaming", "structured_output", "embeddings"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, kw_only=True)
|
|
30
|
+
class _ProviderSpec:
|
|
31
|
+
"""The data that distinguishes one OpenAI-compatible provider from another. ``is_local``
|
|
32
|
+
is True for Ollama -- a local server that authenticates nothing and whose ``base_url``
|
|
33
|
+
is resolved from ``OLLAMA_HOST`` at construction (so the ``base_url`` field is ignored).
|
|
34
|
+
``has_native_json`` marks an endpoint that honours ``response_format`` -- OpenAI and
|
|
35
|
+
Gemini do; Ollama's compat layer does not, so it falls back to prompt-steered JSON.
|
|
36
|
+
Keyword-only so two same-typed model ids cannot be transposed by position."""
|
|
37
|
+
|
|
38
|
+
base_url: str | None
|
|
39
|
+
chat_model: str
|
|
40
|
+
embed_model: str
|
|
41
|
+
has_native_json: bool
|
|
42
|
+
needs_key: bool
|
|
43
|
+
is_local: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The three providers, as data. A `-latest` alias for Gemini, not a pinned name: Google
|
|
47
|
+
# gates pinned model ids (e.g. gemini-2.5-flash-lite) to 404 for newly created API keys,
|
|
48
|
+
# while the alias tracks the current model and stays on the free tier. Private: reached
|
|
49
|
+
# only through _make_openai_client, not exported.
|
|
50
|
+
_SPEC_BY_PROVIDER: dict[str, _ProviderSpec] = {
|
|
51
|
+
"openai": _ProviderSpec(
|
|
52
|
+
base_url=None, chat_model="gpt-4o-mini", embed_model="text-embedding-3-small",
|
|
53
|
+
has_native_json=True, needs_key=True, is_local=False),
|
|
54
|
+
"gemini": _ProviderSpec(
|
|
55
|
+
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
56
|
+
chat_model="gemini-flash-lite-latest", embed_model="text-embedding-004",
|
|
57
|
+
has_native_json=True, needs_key=True, is_local=False),
|
|
58
|
+
"ollama": _ProviderSpec(
|
|
59
|
+
base_url=None, chat_model="llama3.1", embed_model="nomic-embed-text",
|
|
60
|
+
has_native_json=False, needs_key=False, is_local=True),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OpenAICompatibleClient(_BaseClient):
|
|
65
|
+
"""A client over the ``openai`` SDK, pointed at any OpenAI-compatible endpoint. Holds a
|
|
66
|
+
sync client and lazily builds a reusable async client on first async use, plus the chat
|
|
67
|
+
and embedding model ids, so the same class serves OpenAI, Gemini, and Ollama."""
|
|
68
|
+
|
|
69
|
+
_sdk_error: type[Exception]
|
|
70
|
+
_stream_errors: tuple[type[Exception], ...]
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self, *, provider: str, base_url: str | None, api_key: str, model: str,
|
|
74
|
+
embed_model: str, has_native_json: bool, max_tokens: int | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
try:
|
|
77
|
+
import httpx
|
|
78
|
+
from openai import OpenAI, OpenAIError
|
|
79
|
+
except ImportError as err:
|
|
80
|
+
raise ProviderUnavailableError(
|
|
81
|
+
"the openai package is required but could not be imported; reinstall thinchat"
|
|
82
|
+
) from err
|
|
83
|
+
self.model = model
|
|
84
|
+
self.capabilities = _CAPABILITIES
|
|
85
|
+
self._provider = provider
|
|
86
|
+
self._base_url = base_url
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._embed_model = embed_model
|
|
89
|
+
self._has_native_json = has_native_json
|
|
90
|
+
self._max_tokens = max_tokens # None -> omit (OpenAI-compatible; the model decides)
|
|
91
|
+
# Catch only the SDK's own error family so a bug in our code surfaces as itself.
|
|
92
|
+
self._sdk_error = OpenAIError
|
|
93
|
+
# The streaming iteration path does NOT wrap transport failures in OpenAIError, so
|
|
94
|
+
# a mid-stream disconnect raises a raw httpx error; catch that base there too.
|
|
95
|
+
self._stream_errors = (OpenAIError, httpx.HTTPError)
|
|
96
|
+
self._client = OpenAI(base_url=base_url, api_key=api_key)
|
|
97
|
+
self._aclient = None # built on first async use (see _make_aclient)
|
|
98
|
+
|
|
99
|
+
def _make_aclient(self) -> Any:
|
|
100
|
+
from openai import AsyncOpenAI # the sync import above already proved it installed
|
|
101
|
+
return AsyncOpenAI(base_url=self._base_url, api_key=self._api_key)
|
|
102
|
+
|
|
103
|
+
def __repr__(self) -> str: # one class serves three providers; show which
|
|
104
|
+
return f"OpenAICompatibleClient(provider={self._provider!r}, model={self.model!r})"
|
|
105
|
+
|
|
106
|
+
def complete(self, prompt: str, *, system: str | None = None) -> str:
|
|
107
|
+
return self._chat(self._make_messages(prompt, system))
|
|
108
|
+
|
|
109
|
+
async def acomplete(self, prompt: str, *, system: str | None = None) -> str:
|
|
110
|
+
return await self._achat(self._make_messages(prompt, system))
|
|
111
|
+
|
|
112
|
+
def stream(self, prompt: str, *, system: str | None = None) -> Iterator[str]:
|
|
113
|
+
request = self._make_request(self._make_messages(prompt, system), json_mode=False)
|
|
114
|
+
request["stream"] = True
|
|
115
|
+
try:
|
|
116
|
+
# The SDK stream owns the HTTP connection and is a context manager; `with`
|
|
117
|
+
# releases it on an early break or a consumer exception. `Any`: the streamed
|
|
118
|
+
# type is hidden behind **request, so chunks are read defensively via
|
|
119
|
+
# _extract_stream_text.
|
|
120
|
+
sdk_stream: Any = self._client.chat.completions.create(**request)
|
|
121
|
+
with sdk_stream as events:
|
|
122
|
+
for chunk in events:
|
|
123
|
+
delta = _extract_stream_text(chunk)
|
|
124
|
+
if delta:
|
|
125
|
+
yield delta
|
|
126
|
+
except self._stream_errors as err:
|
|
127
|
+
raise LLMError(f"{self._provider} stream failed: {err}") from err
|
|
128
|
+
|
|
129
|
+
async def astream(self, prompt: str, *, system: str | None = None) -> AsyncIterator[str]:
|
|
130
|
+
request = self._make_request(self._make_messages(prompt, system), json_mode=False)
|
|
131
|
+
request["stream"] = True
|
|
132
|
+
try:
|
|
133
|
+
sdk_stream: Any = await self._get_aclient().chat.completions.create(**request)
|
|
134
|
+
async with sdk_stream as events:
|
|
135
|
+
async for chunk in events:
|
|
136
|
+
delta = _extract_stream_text(chunk)
|
|
137
|
+
if delta:
|
|
138
|
+
yield delta
|
|
139
|
+
except self._stream_errors as err:
|
|
140
|
+
raise LLMError(f"{self._provider} stream failed: {err}") from err
|
|
141
|
+
|
|
142
|
+
def embed(self, texts: Sequence[str], *, model: str | None = None) -> list[list[float]]:
|
|
143
|
+
"""Return one embedding vector per input text (empty input -> empty list, no call).
|
|
144
|
+
Raises ``LLMError`` if the API call fails or a reply carries no vector."""
|
|
145
|
+
text_list = list(texts)
|
|
146
|
+
if not text_list:
|
|
147
|
+
return [] # one vector per input; zero inputs -> zero vectors, not an error
|
|
148
|
+
try:
|
|
149
|
+
response = self._client.embeddings.create(model=model or self._embed_model, input=text_list)
|
|
150
|
+
except self._sdk_error as err:
|
|
151
|
+
raise LLMError(f"{self._provider} embedding failed: {err}") from err
|
|
152
|
+
return _extract_embedding_vectors(response)
|
|
153
|
+
|
|
154
|
+
async def aembed(self, texts: Sequence[str], *, model: str | None = None) -> list[list[float]]:
|
|
155
|
+
"""Async twin of ``embed``."""
|
|
156
|
+
text_list = list(texts)
|
|
157
|
+
if not text_list:
|
|
158
|
+
return []
|
|
159
|
+
try:
|
|
160
|
+
response = await self._get_aclient().embeddings.create(model=model or self._embed_model, input=text_list)
|
|
161
|
+
except self._sdk_error as err:
|
|
162
|
+
raise LLMError(f"{self._provider} embedding failed: {err}") from err
|
|
163
|
+
return _extract_embedding_vectors(response)
|
|
164
|
+
|
|
165
|
+
# Native JSON mode where the endpoint honours it, else the base's prompt-steered path.
|
|
166
|
+
def _text_for_parse(self, prompt: str, system: str) -> str:
|
|
167
|
+
return self._chat(self._make_messages(prompt, system), json_mode=self._has_native_json)
|
|
168
|
+
|
|
169
|
+
async def _atext_for_parse(self, prompt: str, system: str) -> str:
|
|
170
|
+
return await self._achat(self._make_messages(prompt, system), json_mode=self._has_native_json)
|
|
171
|
+
|
|
172
|
+
def _chat(self, messages: list[dict[str, str]], *, json_mode: bool = False) -> str:
|
|
173
|
+
try:
|
|
174
|
+
response = self._client.chat.completions.create(**self._make_request(messages, json_mode=json_mode))
|
|
175
|
+
except self._sdk_error as err:
|
|
176
|
+
raise LLMError(f"{self._provider} completion failed: {err}") from err
|
|
177
|
+
return _extract_chat_text(response)
|
|
178
|
+
|
|
179
|
+
async def _achat(self, messages: list[dict[str, str]], *, json_mode: bool = False) -> str:
|
|
180
|
+
try:
|
|
181
|
+
response = await self._get_aclient().chat.completions.create(**self._make_request(messages, json_mode=json_mode))
|
|
182
|
+
except self._sdk_error as err:
|
|
183
|
+
raise LLMError(f"{self._provider} completion failed: {err}") from err
|
|
184
|
+
return _extract_chat_text(response)
|
|
185
|
+
|
|
186
|
+
def _make_request(self, messages: list[dict[str, str]], *, json_mode: bool) -> dict[str, object]:
|
|
187
|
+
request: dict[str, object] = {"model": self.model, "messages": messages}
|
|
188
|
+
if self._max_tokens is not None: # optional here, so send it only when set
|
|
189
|
+
request["max_tokens"] = self._max_tokens
|
|
190
|
+
if json_mode:
|
|
191
|
+
request["response_format"] = {"type": "json_object"}
|
|
192
|
+
return request
|
|
193
|
+
|
|
194
|
+
def _make_messages(self, prompt: str, system: str | None) -> list[dict[str, str]]:
|
|
195
|
+
messages: list[dict[str, str]] = []
|
|
196
|
+
if system is not None:
|
|
197
|
+
messages.append({"role": "system", "content": system})
|
|
198
|
+
messages.append({"role": "user", "content": prompt})
|
|
199
|
+
return messages
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _make_openai_client(
|
|
203
|
+
provider: str, *, model: str | None = None, api_key: str | None = None,
|
|
204
|
+
max_tokens: int | None = None,
|
|
205
|
+
) -> OpenAICompatibleClient:
|
|
206
|
+
"""Construct one of the OpenAI-compatible clients (openai / gemini / ollama) by name.
|
|
207
|
+
``max_tokens`` caps the reply when set; when None it is omitted and the model decides.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
UnknownProviderError: ``provider`` is not an OpenAI-compatible provider.
|
|
211
|
+
ProviderUnavailableError: the openai SDK is not installed, or the provider needs a
|
|
212
|
+
key and none is set (or passed).
|
|
213
|
+
"""
|
|
214
|
+
spec = _SPEC_BY_PROVIDER.get(provider)
|
|
215
|
+
if spec is None:
|
|
216
|
+
raise UnknownProviderError(
|
|
217
|
+
f"{provider!r} is not an OpenAI-compatible provider; "
|
|
218
|
+
f"choose one of {', '.join(_SPEC_BY_PROVIDER)}"
|
|
219
|
+
)
|
|
220
|
+
key = api_key if api_key is not None else keys.get_api_key(provider)
|
|
221
|
+
if spec.needs_key and not key:
|
|
222
|
+
raise ProviderUnavailableError(
|
|
223
|
+
f"no API key for {provider}: set {keys.ENV_BY_PROVIDER[provider]} or pass api_key="
|
|
224
|
+
)
|
|
225
|
+
base_url = _ollama_base_url() if spec.is_local else spec.base_url
|
|
226
|
+
return OpenAICompatibleClient(
|
|
227
|
+
provider = provider,
|
|
228
|
+
base_url = base_url,
|
|
229
|
+
api_key = key or "ollama", # Ollama ignores the key, but the SDK needs a non-empty one
|
|
230
|
+
model = model or spec.chat_model,
|
|
231
|
+
embed_model = spec.embed_model,
|
|
232
|
+
has_native_json = spec.has_native_json,
|
|
233
|
+
max_tokens = max_tokens,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _ollama_base_url() -> str:
|
|
238
|
+
"""Ollama's OpenAI-compatible endpoint, from ``OLLAMA_HOST`` or the local default. Adds
|
|
239
|
+
a scheme (Ollama accepts a bare ``host:port`` that httpx cannot use) and the ``/v1``
|
|
240
|
+
suffix the SDK expects."""
|
|
241
|
+
raw = os.environ.get("OLLAMA_HOST") or "http://localhost:11434"
|
|
242
|
+
host = (raw if "://" in raw else f"http://{raw}").rstrip("/")
|
|
243
|
+
return host if host.endswith("/v1") else host + "/v1"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _extract_chat_text(response: object) -> str:
|
|
247
|
+
"""The first message's text from a chat-completions response, or an ``LLMError`` when
|
|
248
|
+
the reply carries none (an empty choice list or empty content)."""
|
|
249
|
+
choices = getattr(response, "choices", None)
|
|
250
|
+
if not choices:
|
|
251
|
+
raise LLMError("completion returned no choices")
|
|
252
|
+
text = getattr(getattr(choices[0], "message", None), "content", None)
|
|
253
|
+
if not isinstance(text, str) or not text.strip(): # blank reply is empty, like Claude's
|
|
254
|
+
raise LLMError("completion returned an empty reply")
|
|
255
|
+
return text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _extract_stream_text(chunk: object) -> str | None:
|
|
259
|
+
"""The incremental text of one streaming chunk, or None for a chunk that carries no
|
|
260
|
+
content (role-only openers, finish markers)."""
|
|
261
|
+
choices = getattr(chunk, "choices", None)
|
|
262
|
+
if not choices:
|
|
263
|
+
return None
|
|
264
|
+
delta = getattr(getattr(choices[0], "delta", None), "content", None)
|
|
265
|
+
return delta if isinstance(delta, str) else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _extract_embedding_vectors(response: object) -> list[list[float]]:
|
|
269
|
+
"""The embedding vectors from an embeddings response, one per input, in order. An item
|
|
270
|
+
that carries no vector is an error, not a silently-empty result."""
|
|
271
|
+
data = getattr(response, "data", None)
|
|
272
|
+
if not isinstance(data, list) or not data:
|
|
273
|
+
raise LLMError("embedding returned no vectors")
|
|
274
|
+
# The API tags each item with its input `index`; sort by it so the vectors line up with
|
|
275
|
+
# the input order even if the response arrives (or is batched) out of order.
|
|
276
|
+
items = sorted(data, key=lambda item: getattr(item, "index", 0))
|
|
277
|
+
vectors: list[list[float]] = []
|
|
278
|
+
for item in items:
|
|
279
|
+
embedding = getattr(item, "embedding", None)
|
|
280
|
+
if not isinstance(embedding, list) or not embedding:
|
|
281
|
+
raise LLMError("embedding response held an item with no vector")
|
|
282
|
+
vectors.append(list(embedding))
|
|
283
|
+
return vectors
|
thinchat/providers.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Name a provider, get a client.
|
|
2
|
+
|
|
3
|
+
The one place that maps each provider name to its client factory, so a caller -- a CLI, a
|
|
4
|
+
config value, another package -- asks for a client by provider string and never re-authors
|
|
5
|
+
the mapping. The roster (``PROVIDERS``) is *derived* from the factory map, so the two
|
|
6
|
+
cannot drift. Adding a provider is one entry here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from thinchat.claude_client import _make_claude_client
|
|
15
|
+
from thinchat.client import Client, Provider
|
|
16
|
+
from thinchat.errors import UnknownProviderError
|
|
17
|
+
from thinchat.openai_client import _make_openai_client
|
|
18
|
+
|
|
19
|
+
__all__ = ["PROVIDERS", "make_client"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _ClientFactory(Protocol):
|
|
23
|
+
"""The call shape every provider factory shares once its provider name is bound: the
|
|
24
|
+
override knobs (model, api_key, max_tokens) in, a client out. Typed (not
|
|
25
|
+
``Callable[..., Client]``) so a call with a mistyped kwarg is caught statically."""
|
|
26
|
+
|
|
27
|
+
def __call__(
|
|
28
|
+
self, *, model: str | None = None, api_key: str | None = None,
|
|
29
|
+
max_tokens: int | None = None,
|
|
30
|
+
) -> Client: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# One factory per provider -- the single source of the roster. claude has its own factory;
|
|
34
|
+
# openai/gemini/ollama share the OpenAI-compatible one (bound to their name).
|
|
35
|
+
_FACTORY_BY_PROVIDER: dict[Provider, _ClientFactory] = {
|
|
36
|
+
"claude": _make_claude_client,
|
|
37
|
+
"openai": partial(_make_openai_client, "openai"),
|
|
38
|
+
"gemini": partial(_make_openai_client, "gemini"),
|
|
39
|
+
"ollama": partial(_make_openai_client, "ollama"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# The providers thinchat supports, in insertion order -- derived from the factory map so
|
|
43
|
+
# the roster and the dispatch never drift.
|
|
44
|
+
PROVIDERS: tuple[Provider, ...] = tuple(_FACTORY_BY_PROVIDER)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def make_client(
|
|
48
|
+
provider: str, *, model: str | None = None, api_key: str | None = None,
|
|
49
|
+
max_tokens: int | None = None,
|
|
50
|
+
) -> Client:
|
|
51
|
+
"""Construct the client for ``provider`` (one of ``PROVIDERS``). ``model`` overrides
|
|
52
|
+
the provider's default model; ``api_key`` overrides the environment key (for tests, or
|
|
53
|
+
a caller that manages its own secrets); ``max_tokens`` caps the reply length (a positive
|
|
54
|
+
integer, validated by the provider). When ``max_tokens`` is None, claude falls back to a
|
|
55
|
+
default (Anthropic requires the field) and the OpenAI-compatible providers omit it,
|
|
56
|
+
letting the model decide.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
UnknownProviderError: ``provider`` is not one of ``PROVIDERS``.
|
|
60
|
+
ProviderUnavailableError: the provider's SDK is not installed, or it needs a key
|
|
61
|
+
and none is available.
|
|
62
|
+
"""
|
|
63
|
+
if provider not in _FACTORY_BY_PROVIDER:
|
|
64
|
+
raise UnknownProviderError(
|
|
65
|
+
f"unknown provider {provider!r}; choose one of {', '.join(PROVIDERS)}"
|
|
66
|
+
)
|
|
67
|
+
factory = _FACTORY_BY_PROVIDER[provider] # `provider` narrowed to a known key by the check above
|
|
68
|
+
return factory(model=model, api_key=api_key, max_tokens=max_tokens)
|
thinchat/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Turn a model's text reply into a JSON object, and ask for one in the first place.
|
|
2
|
+
|
|
3
|
+
Structured output has two halves. A client that supports it natively (OpenAI's
|
|
4
|
+
``response_format``) constrains the reply to a schema at the API; one that does not is
|
|
5
|
+
steered by appending the schema to the prompt. Either way the reply is text that must be
|
|
6
|
+
parsed, and models wrap JSON in prose or ```json fences often enough that a bare
|
|
7
|
+
``json.loads`` is too brittle -- so parsing strips the common wrappers first. Schema
|
|
8
|
+
validation stops at "it is a JSON object": deep validation would need a schema library,
|
|
9
|
+
and the caller, which owns the schema, checks its own fields.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from thinchat.errors import LLMError
|
|
17
|
+
|
|
18
|
+
__all__ = ["make_json_instruction", "parse_json"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_json_instruction(schema: dict[str, object]) -> str:
|
|
22
|
+
"""The line appended to a system prompt to steer a client with no native structured
|
|
23
|
+
output: reply as JSON matching this schema, nothing else."""
|
|
24
|
+
return (
|
|
25
|
+
"Reply with a single JSON object matching this JSON Schema, and nothing else -- "
|
|
26
|
+
f"no prose, no code fences:\n{json.dumps(schema)}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_json(reply: str) -> dict[str, object]:
|
|
31
|
+
"""Parse ``reply`` into a JSON object. Tolerates a ```json fence and a prose preamble or
|
|
32
|
+
trailer around the object; it takes the outermost ``{`` .. ``}`` span, so brace
|
|
33
|
+
characters *inside* the surrounding prose can widen the span and fail the parse (raising,
|
|
34
|
+
never returning a wrong object). Returns the parsed object.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
LLMError: the reply is empty, holds no JSON object, or the JSON is not an object.
|
|
38
|
+
"""
|
|
39
|
+
text = _unfence(reply).strip()
|
|
40
|
+
if not text:
|
|
41
|
+
raise LLMError("structured request returned an empty reply")
|
|
42
|
+
# A model that adds a sentence around the object still parses if we take the outermost
|
|
43
|
+
# brace span; a bare json.loads would fail on the surrounding prose.
|
|
44
|
+
start, end = text.find("{"), text.rfind("}")
|
|
45
|
+
if start == -1 or end <= start:
|
|
46
|
+
raise LLMError(f"structured reply held no JSON object: {text[:120]}")
|
|
47
|
+
try:
|
|
48
|
+
parsed = json.loads(text[start : end + 1])
|
|
49
|
+
except json.JSONDecodeError as err:
|
|
50
|
+
raise LLMError(f"structured reply was not valid JSON: {text[:120]}") from err
|
|
51
|
+
if not isinstance(parsed, dict):
|
|
52
|
+
raise LLMError("structured reply was JSON but not an object")
|
|
53
|
+
return parsed
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unfence(text: str) -> str:
|
|
57
|
+
"""Strip a leading ```json / ``` fence and its closing ``` if present, else return
|
|
58
|
+
``text`` unchanged -- the one wrapper common enough to handle before brace-scanning."""
|
|
59
|
+
stripped = text.strip()
|
|
60
|
+
if not stripped.startswith("```"):
|
|
61
|
+
return text
|
|
62
|
+
body = stripped[3:]
|
|
63
|
+
if body[:4].lower() == "json":
|
|
64
|
+
body = body[4:]
|
|
65
|
+
return body.rsplit("```", 1)[0]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thinchat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny, unified client for four LLM providers: claude, openai, gemini, ollama.
|
|
5
|
+
Project-URL: Homepage, https://github.com/seokhoonj/thinchat
|
|
6
|
+
Project-URL: Repository, https://github.com/seokhoonj/thinchat
|
|
7
|
+
Project-URL: Issues, https://github.com/seokhoonj/thinchat/issues
|
|
8
|
+
Author-email: seokhoonj <seokhoonj@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: claude,completion,embeddings,gemini,llm,ollama,openai
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: anthropic>=0.40
|
|
20
|
+
Requires-Dist: httpx>=0.23
|
|
21
|
+
Requires-Dist: openai>=1.30
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# thinchat
|
|
30
|
+
|
|
31
|
+
**English** | [한국어](README.ko.md)
|
|
32
|
+
|
|
33
|
+
A tiny, unified client for four LLM providers — **claude, openai, gemini, ollama**.
|
|
34
|
+
|
|
35
|
+
Name a provider, then call it. Every client offers completion — whole, streamed, or
|
|
36
|
+
JSON-structured — and, where the provider has one, embeddings, each with an async twin. No
|
|
37
|
+
gateway, no router, no cost tracking: just the calls, over the providers' own SDKs.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
```mermaid
|
|
42
|
+
flowchart LR
|
|
43
|
+
M["make_client(provider)"] --> C["Client<br/>openai-compatible · or claude"]
|
|
44
|
+
C --> V["complete · stream · parse · embed<br/>(+ a-prefixed async twins)"]
|
|
45
|
+
V --> S{{"vendor SDK"}}
|
|
46
|
+
S -->|ok| O(["str · dict · list float · stream"])
|
|
47
|
+
S -->|"SDK / transport error"| E(["LLMError"])
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`make_client` looks the provider up in one factory map: openai/gemini/ollama share a single
|
|
51
|
+
class over the openai SDK (they differ only in data); claude has its own over anthropic.
|
|
52
|
+
A call builds the request, hits the SDK, and either extracts the reply or maps the failure
|
|
53
|
+
to one `LLMError`.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pip install thinchat
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both provider SDKs (openai and anthropic) come with it, so every provider works out of the
|
|
62
|
+
box; each is imported lazily the first time you construct its client.
|
|
63
|
+
|
|
64
|
+
## Use
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from thinchat import make_client
|
|
68
|
+
|
|
69
|
+
llm = make_client("claude") # key from CLAUDE_API_KEY
|
|
70
|
+
print(llm.complete("Say hi in one word."))
|
|
71
|
+
print(llm.complete("Name a color.", system="Answer in one word.")) # system= steers any verb
|
|
72
|
+
|
|
73
|
+
# Structured output: reply parsed into a JSON object. The schema steers generation
|
|
74
|
+
# but is not validated locally, so check the returned dict's fields yourself.
|
|
75
|
+
verdict = llm.parse(
|
|
76
|
+
"Is this an ad? 'Buy now, 50% off — order today'",
|
|
77
|
+
schema={"type": "object",
|
|
78
|
+
"properties": {"is_ad": {"type": "boolean"}, "reason": {"type": "string"}},
|
|
79
|
+
"required": ["is_ad"]},
|
|
80
|
+
)
|
|
81
|
+
print(verdict["is_ad"])
|
|
82
|
+
|
|
83
|
+
# Streaming.
|
|
84
|
+
for chunk in make_client("claude").stream("Count to five."):
|
|
85
|
+
print(chunk, end="")
|
|
86
|
+
|
|
87
|
+
# Embeddings (openai / gemini / ollama; Claude has none).
|
|
88
|
+
vectors = make_client("openai").embed(["hello", "world"])
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every verb has an async twin — `acomplete`, `astream`, `aparse`, `aembed`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
llm = make_client("claude")
|
|
95
|
+
text = await llm.acomplete("Summarize in one line: ...")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Providers
|
|
99
|
+
|
|
100
|
+
| provider | key env | embeddings |
|
|
101
|
+
|----------|-------------------|------------|
|
|
102
|
+
| `claude` | `CLAUDE_API_KEY` | no |
|
|
103
|
+
| `openai` | `OPENAI_API_KEY` | yes |
|
|
104
|
+
| `gemini` | `GEMINI_API_KEY` | yes |
|
|
105
|
+
| `ollama` | none (local) | yes |
|
|
106
|
+
|
|
107
|
+
openai, gemini, and ollama speak the same OpenAI-compatible API, so one SDK serves all
|
|
108
|
+
three; only the base URL, key, and default models differ. Ollama runs locally
|
|
109
|
+
(`OLLAMA_HOST`, default `http://localhost:11434`) and needs no key.
|
|
110
|
+
|
|
111
|
+
`max_tokens` caps the reply length — `make_client("claude", max_tokens=8192)`. Anthropic
|
|
112
|
+
requires the field, so claude defaults to 4096; the OpenAI-compatible providers omit it unless
|
|
113
|
+
you pass one, letting the model decide.
|
|
114
|
+
|
|
115
|
+
Keys are read from the environment. Set them once in your shell profile (`~/.bashrc`,
|
|
116
|
+
`~/.zshrc`) so every session picks them up — thinchat is a library and never imposes a file
|
|
117
|
+
location of its own:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
export CLAUDE_API_KEY="sk-ant-..."
|
|
121
|
+
export OPENAI_API_KEY="sk-..."
|
|
122
|
+
export GEMINI_API_KEY="..." # ollama runs locally and needs no key
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Or pass a key explicitly, which overrides the environment:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
llm = make_client("claude", api_key="sk-ant-...", model="claude-haiku-4-5-20251001")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Capabilities
|
|
132
|
+
|
|
133
|
+
A client whose provider lacks a capability raises `UnsupportedError`. The capabilities are
|
|
134
|
+
`completion`, `streaming`, `structured_output`, and `embeddings`; check first with `supports`:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
make_client("claude").supports("embeddings") # False
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Errors
|
|
141
|
+
|
|
142
|
+
Everything thinchat raises on purpose derives from `ThinchatError`, so one `except` handles
|
|
143
|
+
the package's failures:
|
|
144
|
+
|
|
145
|
+
- `UnknownProviderError` — the name isn't one of the four providers.
|
|
146
|
+
- `ProviderUnavailableError` — the provider's SDK isn't installed, or no API key is set.
|
|
147
|
+
- `UnsupportedError` — the provider lacks the capability (e.g. embeddings on Claude).
|
|
148
|
+
- `LLMError` — the API call failed, or the reply was empty or malformed.
|
|
149
|
+
|
|
150
|
+
## Lifecycle
|
|
151
|
+
|
|
152
|
+
A client holds an HTTP connection pool. For a one-off script you can ignore it; for a
|
|
153
|
+
server that builds a client per request, close it so connections do not leak — use it as a
|
|
154
|
+
context manager, or call `close()` / `aclose()`:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
with make_client("claude") as llm:
|
|
158
|
+
llm.complete("...") # sync: closes the pool on exit
|
|
159
|
+
|
|
160
|
+
async with make_client("claude") as llm:
|
|
161
|
+
await llm.acomplete("...") # async: closes the async pool too
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`close()` frees the sync pool; if you drove async verbs, release with `aclose()` or
|
|
165
|
+
`async with` so the async pool is closed as well.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
thinchat/__init__.py,sha256=7KEdu_en4HK1RwDOMfDlsHu1ZS-nZcndoDdx-uWXDhM,1771
|
|
2
|
+
thinchat/claude_client.py,sha256=u7HKCJ5khHfWYmjrJfbnPKSCl1MmhqutbTIuRDq95CU,6271
|
|
3
|
+
thinchat/client.py,sha256=6ZqofpyZGCIhGS92TtTenLoK28i1dRhnrSASJM3ERJE,10353
|
|
4
|
+
thinchat/errors.py,sha256=tOhURTUmQ4a4qIvrw6WhpGEHkJt5GmvsNT-F8RpUkpw,1772
|
|
5
|
+
thinchat/keys.py,sha256=YzX0Qnzm6GCmQ4794aftYHKfF0VtiWt6C7R9UXcujgU,1314
|
|
6
|
+
thinchat/openai_client.py,sha256=MH16O5wmEgKk7tasLR5yTxGDXlhGqqTS7ygpZfNg6II,13930
|
|
7
|
+
thinchat/providers.py,sha256=fUXidKH0pJWxm8tXwbs7TeJomP_WqUNL2wr8MIgnkYM,2950
|
|
8
|
+
thinchat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
thinchat/structured_output.py,sha256=l5mdzF0SsWFrB_BoPBt1CkEjiC3K4ZqmYM6Qges2lbQ,2858
|
|
10
|
+
thinchat-0.1.0.dist-info/METADATA,sha256=dibtTqv_6yDkemiT5HS3EBpJ7fpn01fXe9pmPm6sLJw,6078
|
|
11
|
+
thinchat-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
thinchat-0.1.0.dist-info/licenses/LICENSE,sha256=O5S_tbTQR3TmHsllCuNieSABx8AubsZe-hyiFVsOE8o,1066
|
|
13
|
+
thinchat-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 seokhoonj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|