langchaint 0.3.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Hopfensperger
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.
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchaint
3
+ Version: 0.3.0
4
+ Summary: It ain't langchain.
5
+ Keywords: llm,anthropic,openai,claude,async
6
+ Author: John Hopfensperger
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Dist: pydantic>=2.13.4
14
+ Requires-Dist: anthropic>=0.116.0 ; extra == 'all'
15
+ Requires-Dist: openai>=2.45.0 ; extra == 'all'
16
+ Requires-Dist: anthropic>=0.116.0 ; extra == 'anthropic'
17
+ Requires-Dist: openai>=2.45.0 ; extra == 'openai'
18
+ Requires-Dist: opentelemetry-api>=1.30.0 ; extra == 'otel'
19
+ Requires-Python: >=3.13
20
+ Project-URL: Homepage, https://github.com/s-banach/langchaint
21
+ Project-URL: Repository, https://github.com/s-banach/langchaint
22
+ Project-URL: Issues, https://github.com/s-banach/langchaint/issues
23
+ Provides-Extra: all
24
+ Provides-Extra: anthropic
25
+ Provides-Extra: openai
26
+ Provides-Extra: otel
27
+ Description-Content-Type: text/markdown
28
+
29
+ # langchaint
30
+
31
+ Provider-neutral async LLM client over the official provider SDKs (anthropic >= 0.116.0, openai >= 2.45.0).
32
+ Alpha: the API is unstable and may change without notice.
33
+
34
+ ## Design
35
+
36
+ **Generation only via binding.**
37
+ `LLM` has no generate methods.
38
+ `LLM.bind(...) -> BoundLLM[OutputT]` freezes everything that determines the cacheable prompt prefix: `system_prompt`, `tool_manager`, `inference_params`, `tool_choice`, `parallel_tool_calls`, `automatic_prompt_caching`.
39
+ `response_format` is frozen too, as the field that fixes the output type.
40
+ `automatic_prompt_caching` has no default: caching changes billing, so every `bind` states it.
41
+ A `TextPart` or `ImagePart` with `cache_breakpoint=True` places a prompt-cache boundary at exactly that part, under either `automatic_prompt_caching` value, so binding `False` and marking parts is the fully user-specified caching configuration.
42
+ `generate_many(conversations, warm_cache=True)` runs the first conversation to completion before admitting the rest, so a batch sharing a cached prefix pays one cache write instead of one per in-flight item; it is opt-in because it costs one item of serial latency.
43
+ `system_prompt` also binds as a sequence of `TextPart`s, so a boundary can sit inside the frozen prefix (stable instructions marked, semi-stable context after).
44
+ There are no per-call parameter overrides; changing parameters is `rebind(...)`, which returns a new `BoundLLM` with the SDK keyword arguments converted again.
45
+ `bind(response_format=Model)` returns `BoundLLM[Model]`; without `response_format` it returns `BoundLLM[str]`, selected by overload.
46
+ `rebind` carries the same overload, so `rebind(response_format=Model)` switches the output type to `BoundLLM[Model]`, `rebind(response_format=None)` switches it back to `BoundLLM[str]`, and leaving it out keeps the current type.
47
+ Every generate and stream method takes a conversation of `Message`s; a bare `str` is accepted as shorthand for a conversation of one `UserMessage` holding that text.
48
+
49
+ **The catalog is a constructor function per backend returning a ready LLM (anthropic adds a second for Bedrock).**
50
+ `from langchaint.openai import openai_model` then `openai_model("gpt-5.6-terra")`, and `from langchaint.anthropic import anthropic_model` then `anthropic_model("claude-sonnet-5")`, take the provider's own model identifier (a `Literal`, so typos fail the type check), look up the public prices, construct the adapter, and wrap it in an `LLM`.
51
+ `client=None` constructs the native first-party SDK client from environment credentials.
52
+ Anthropic Bedrock is a second sibling constructor, `anthropic_bedrock_model(model, aws_region=...)`: it names the same catalog model and reads the model's Bedrock surface (which of two SDK client classes) and wire model id from a table, so the application names neither the client class nor the Bedrock id.
53
+ Constructing an adapter directly covers models outside the catalog.
54
+
55
+ **The SDKs are optional dependencies, one extra per backend.**
56
+ The neutral core (`LLM`, the message tree, the error taxonomy) imports no SDK, so `import langchaint` needs neither package.
57
+ Each backend lives in its own subpackage that imports its SDK at module top: install `langchaint[openai]` or `langchaint[anthropic]` (or `langchaint[all]`) for the ones you use, and importing `langchaint.openai` without the openai package raises a `ModuleNotFoundError` naming the extra to install.
58
+ The import path is the boundary: only code that reaches for a backend requires its SDK, and a type checker following `langchaint.openai` never resolves anthropic's types.
59
+ OTel tracing follows the same pattern: `langchaint.tracing` imports only opentelemetry-api, install `langchaint[otel]`, and it stays off `import langchaint`.
60
+
61
+ **Adapters wrap SDK clients and delegate to the SDK.**
62
+ `AnthropicMessagesProvider(client=AsyncAnthropic(...))` and `OpenAIResponsesProvider(client=AsyncOpenAI(...))` call `messages.create/parse/stream` and `responses.create/parse/stream`; stream assembly and structured-output parsing are the SDK's, not hand-written.
63
+ Adapters store a `with_options(max_retries=0)` copy of the client, so the SDK never retries beneath the package's retry loop.
64
+ OpenAI support is the Responses API only: every supported OpenAI model speaks it, so there is no Chat Completions adapter.
65
+ The adapter always sends `store=False` because conversation state is the caller's conversation argument.
66
+ Anthropic Bedrock is two distinct SDK surfaces, the legacy `InvokeModel` client `AsyncAnthropicBedrock` and the Messages-API client `AsyncAnthropicBedrockMantle`, and the catalog models split across them, so `anthropic_bedrock_model` reads the client class and wire model id per model from `ANTHROPIC_BEDROCK`.
67
+ OpenAI on Bedrock is the bundled `AsyncBedrockOpenAI` passed as `client`; there is no Converse adapter.
68
+ Requests travel as typed frozen dataclasses whose optional fields are `X | Omit`, passed to the SDK as explicit keywords: no `**kwargs`, no hand-written wire TypedDicts, and the SDK overloads resolve without casts.
69
+
70
+ **Anthropic prompt caching is placed by the adapter.**
71
+ A bind-time `cache_control` marker goes on the system block (or the last tool when there is no system prompt), and a per-request marker goes on the last block of the last message, so the breakpoint follows a growing conversation.
72
+ Placement is manual because `messages.parse` lacks the top-level `cache_control` parameter; manual placement keeps create/parse/stream uniform.
73
+ Every marker's TTL is the adapter's `cache_ttl` (`"5m"` default, `"1h"` for entries that must survive longer gaps at 2x write cost), a keyword of `anthropic_model` and `anthropic_bedrock_model`.
74
+
75
+ **Usage counters partition, cost is the adapter's job.**
76
+ `Usage` stores `input_tokens_cache_read`, `input_tokens_cache_write`, `input_tokens_cache_none`, and `output_tokens`; the three input counters are a disjoint partition and `input_tokens_total` is derived.
77
+ `input_tokens_total_provider_reported` holds what the provider itself reported: openai's `input_tokens` includes cached and cache-write tokens, so the adapter fills it and a validator cross-checks the partition; anthropic reports no all-inclusive total, so it stays None.
78
+ The counters are validated non-negative, which is what guards the openai path: there the partition is derived by subtraction and sums to the reported total by construction.
79
+ `cost_in_usd` is computed inside the adapter from raw provider counts against a `PricingTable`, because providers split counters the normalized `Usage` collapses: anthropic bills 5-minute and 1-hour cache writes at different rates, and an adapter that sees 1-hour writes without `cache_write_1h_usd_per_million_tokens` raises `AbortBatchError` rather than misbill.
80
+
81
+ **Success is a `Response`, failure is a `GenerationError`.**
82
+ A generate that succeeds returns a frozen `Response[OutputT]` with every field present; one that ends terminally raises (or, in a batch, returns) a `GenerationError`.
83
+ Its three leaves are the terminal per-item outcomes: `RetriesExhaustedError` (transient budget spent), `RefusalError` (the model refused on the structured path), and `ExceededMaxCompletionTokensError` (the structured response hit the token cap before its JSON parsed).
84
+ On the base, `stop_reason` is `StopReason | None`: `"refusal"` or `"max_tokens"` on those two leaves, `None` on `RetriesExhaustedError`, whose attempts never reached a completed turn.
85
+ Both hold `attempt_records`, one `AttemptRecord` per request sent: raw `time.monotonic()` start and end readings bracketing the send only (slot waits and backoff sleeps excluded, so rate limiting is distinguishable from slow requests), the attempt's `TransientError` (None on the attempt that succeeded or a rejected 200), and the attempt's `usage`/`cost_in_usd` (None for a transport failure that billed nothing).
86
+ On a `Response` every record but the last failed; on a `GenerationError` the records describe the terminal outcome.
87
+ `attempts` is derived from the records on both.
88
+ Both also carry `model`, `provider_name`, and `elapsed_seconds` (first request to completion, waits included, so stored rather than derived), so the module-level `to_row(result)` flattens either to the same scalar keys and a mixed list of successes and failures is one table.
89
+ A `GenerationError` derives `usage`/`cost_in_usd` from its records, so a refusal or truncation reports its real cost while a retry-exhausted item whose attempts billed nothing reports zero; `error_text` carries the failure reason.
90
+ On a `Response`, `usage`, `cost_in_usd` (the successful attempt's own), `stop_reason`, `assistant_message`, and `raw` are always present.
91
+ `raw` is the SDK's own response model held by reference; it is never dumped to a dict, because dumping deep-copies every response body per request.
92
+
93
+ **One RateLimiter owns retrying and pacing.**
94
+ `RateLimiter` holds `max_attempts`, `backoff_base_seconds`, `backoff_max_seconds`, and `max_in_flight`; it is stateful and shareable, so one instance passed to several `LLM`s is one shared budget for the account they hit.
95
+ Its slot gates every request start on every path (first attempts, retries, batch items, stream openings); backoff sleeps outside the slot.
96
+ There is deliberately no `requests_per_minute`: an in-flight bound self-adjusts throughput along request duration, while a client-side rate number models one dimension of the provider's multi-dimensional limit and goes stale with the account tier.
97
+ A rate-limit error (`Provider.classify` returning `"rate_limit"`, or any error naming a server-stated retry-after) pauses admission for everyone sharing the limiter: for the server-stated wait when one was sent (parsed from the `retry-after-ms` / `retry-after` headers onto `TransientError.retry_after_seconds`, capped at 60 seconds), else for the failing task's backoff delay.
98
+ After the pause, admission stays limited to one probe request at a time until the probe succeeds; other transient errors (timeouts, 5xx) pause nobody, because they say nothing about the account's quota.
99
+
100
+ **Retry stays in the package, classification in the adapter.**
101
+ Adapters raise `TransientError`, `AbortBatchError`, or a `GenerationError` leaf directly where they know the answer: rate limits and overload (transient), a bad request (abort), and an empty structured parse, which splits three ways (a refusal is `RefusalError`, a token-cap truncation is `ExceededMaxCompletionTokensError`, anything else is `TransientError`).
102
+ The retry loop retries only `TransientError`; it honors the rest without asking.
103
+ Unrecognized exceptions go through `Provider.classify`, which returns `"rate_limit"`, `"transient"`, or `"abort"`, and anything it does not recognize is abort.
104
+ `generate_one` raises `RetriesExhaustedError` for transient exhaustion, `RefusalError` or `ExceededMaxCompletionTokensError` on the structured path, and `AbortBatchError` for an abort classification; the first three share the `GenerationError` base a caller can catch at once.
105
+ `generate_many` returns `list[Response[OutputT] | GenerationError]`, so a terminal per-item failure is a row in its slot rather than a raise, while an `AbortBatchError` still cancels the siblings and raises; results stay order-aligned.
106
+
107
+ **Streaming is a handle.**
108
+ `stream_one` returns a `StreamHandle`: an async iterator of `StreamItem = str | ToolCall`, an idempotent `await handle.final()` returning the assembled `Response`, and an async context manager so abandoning a stream closes the connection.
109
+ Text chunks are the provider SDK's own strings passed through without a wrapper class or copy, and each `ToolCall` is yielded once, complete, when its block closes.
110
+ There are deliberately no tool-call delta items (a consumer cannot act on partial argument JSON, and both SDKs accumulate the arguments and hand over the finished call) and no usage or stop items (usage, `cost_in_usd`, and `stop_reason` live on `final()`'s `Response`, and a bare-`str` stop reason could not share a union with bare-`str` text chunks).
111
+ Connection failures before the first yielded item retry under the `RateLimiter`; after the first yielded item nothing retries, because replaying chunks the caller already consumed would duplicate output.
112
+ An open stream holds one `RateLimiter` slot from opening until it closes or exhausts, so long-lived streams count against `max_in_flight`.
113
+
114
+ **Tools are explicit, dispatch is owned.**
115
+ A `Tool` is an async function taking one pydantic model and returning str or a sequence of content parts (text and images the model then sees), plus an explicit name and description; the args model is the schema source, so there is no signature introspection and no docstring scraping.
116
+ Tool content is model-facing, so it is exactly `MessageContent` (`str | Sequence[Part]`, the model-facing message body, aliased in `messages.py`): a function with a typed result serializes it to that form itself.
117
+ A function may instead return a `ToolOutputExplicit` wrapping that content plus `is_error` and `app_data` (the app-facing channel, which does carry a typed `BaseModel` or `Mapping` the model never sees).
118
+ `Tool.validate_and_run` validates raw call JSON against `args_model` and runs the function; it lives on `Tool` because there the args type parameter is concrete, so the validated arguments reach the function fully typed.
119
+ `Tool.dispatch` returns `DispatchHandled[AppDataT] | DispatchInvalidToolArgs`, so on the handled outcome a caller that dispatched a known tool reads `app_data` back at its concrete type with no `isinstance`.
120
+ `ToolManager` serializes the tools provider-neutrally, resolves the called name, and returns each outcome as a `DispatchOutcome`, a three-arm union over a heterogeneous tool set (where `app_data` erases to `BaseModel | Mapping[str, object] | None`): `DispatchHandled` carries the model-facing `ToolMessage` the caller appends to the conversation, paired with the function's `app_data` (data the model never sees); `DispatchInvalidToolArgs` carries a default `is_error` `ToolMessage` plus the pydantic `ValidationError` as a required field, so a caller that authors its own reply reads `validation_error.errors()` with no narrowing crutch; `DispatchUnknownTool` carries a default `is_error` `ToolMessage` naming the held tools plus the off-list `called_name`.
121
+ Every arm carries `tool_message`, so a caller that only appends the reply reads `result.tool_message` with no `match`.
122
+ An off-list tool name becomes a `DispatchUnknownTool` outcome rather than a raise, just like argument JSON the model got wrong becomes a `DispatchInvalidToolArgs` outcome: both are model data the model can correct (a provider can emit a name outside the sent schemas, and a rebind can strand an earlier turn's `tool_call`).
123
+ `tool_choice` is the neutral vocabulary `"auto" | "required" | SpecificTool | "none"` (anthropic's `"any"` maps from `"required"`); `parallel_tool_calls: bool` maps to anthropic `disable_parallel_tool_use` and openai `parallel_tool_calls`.
124
+
125
+ **The ReAct loop is a recipe, not vendored code.**
126
+ There is no agent class.
127
+ The loop is ~15 lines and is correct because the hard parts (retries, pacing, classification, validation) live below `generate_one` and `dispatch`, so a hand-copied loop cannot be subtly wrong.
128
+ The non-streaming loop runs over `generate_one`; the streaming loop runs over `stream_one`, printing text chunks as they arrive and dispatching the completed `ToolCall`s between turns, reading `dispatch(call).tool_message` for the ToolMessage it appends and matching `DispatchHandled` for the function's model-invisible `app_data` or `DispatchInvalidToolArgs` for the `validation_error`.
129
+ Owning the loop is what lets a caller enforce a budget mid-run, stream tokens, or swap the binding on a tool result, none of which a fixed agent surface exposes.
130
+
131
+ ## Layout
132
+
133
+ CLAUDE.md design, naming, and review rules
134
+ README.md this file
135
+ src/langchaint/
136
+ messages.py message and part models, StopReason
137
+ usage.py Usage counters
138
+ inference_params.py InferenceParams
139
+ exceptions.py error vocabulary: TransientError,
140
+ AbortBatchError, AttemptRecord,
141
+ GenerationError and its leaves
142
+ response.py Response[OutputT], to_row
143
+ rate_limiter.py RateLimiter
144
+ tools.py Tool, ToolSchema, ToolManager
145
+ provider.py Binding, ToolChoice, PricingTable,
146
+ StreamItem, ProviderResult,
147
+ ProviderStream, BoundProvider, Provider
148
+ streaming.py StreamHandle
149
+ llm.py LLM, BoundLLM, the retry loop
150
+ anthropic/ the anthropic backend (needs the anthropic
151
+ SDK): __init__ has anthropic_model,
152
+ ANTHROPIC_PRICING, AnthropicModelName;
153
+ messages_provider.py has the adapter
154
+ AnthropicMessagesProvider
155
+ openai/ the openai backend (needs the openai SDK):
156
+ __init__ has openai_model, OPENAI_PRICING,
157
+ OpenAIModelName; responses_provider.py has
158
+ the adapter OpenAIResponsesProvider
159
+ tracing/ OTel span tracing (needs opentelemetry-api,
160
+ install langchaint[otel]): __init__ has
161
+ TracedLLM, TracedBoundLLM, TracedStreamHandle
162
+
163
+ ## Verification
164
+
165
+ Run `scripts/CI.sh`; it runs `pyrefly check`, `ruff check`, and `pytest` through `uv run`, so the tools resolve from the locked dev dependency group, not a hand-activated `.venv`.
166
+ All must pass with zero errors.
167
+ The tests are offline: they feed constructed SDK objects into the adapter helpers and drive `BoundLLM`/`StreamHandle` with stub providers, so they need no API keys.
@@ -0,0 +1,139 @@
1
+ # langchaint
2
+
3
+ Provider-neutral async LLM client over the official provider SDKs (anthropic >= 0.116.0, openai >= 2.45.0).
4
+ Alpha: the API is unstable and may change without notice.
5
+
6
+ ## Design
7
+
8
+ **Generation only via binding.**
9
+ `LLM` has no generate methods.
10
+ `LLM.bind(...) -> BoundLLM[OutputT]` freezes everything that determines the cacheable prompt prefix: `system_prompt`, `tool_manager`, `inference_params`, `tool_choice`, `parallel_tool_calls`, `automatic_prompt_caching`.
11
+ `response_format` is frozen too, as the field that fixes the output type.
12
+ `automatic_prompt_caching` has no default: caching changes billing, so every `bind` states it.
13
+ A `TextPart` or `ImagePart` with `cache_breakpoint=True` places a prompt-cache boundary at exactly that part, under either `automatic_prompt_caching` value, so binding `False` and marking parts is the fully user-specified caching configuration.
14
+ `generate_many(conversations, warm_cache=True)` runs the first conversation to completion before admitting the rest, so a batch sharing a cached prefix pays one cache write instead of one per in-flight item; it is opt-in because it costs one item of serial latency.
15
+ `system_prompt` also binds as a sequence of `TextPart`s, so a boundary can sit inside the frozen prefix (stable instructions marked, semi-stable context after).
16
+ There are no per-call parameter overrides; changing parameters is `rebind(...)`, which returns a new `BoundLLM` with the SDK keyword arguments converted again.
17
+ `bind(response_format=Model)` returns `BoundLLM[Model]`; without `response_format` it returns `BoundLLM[str]`, selected by overload.
18
+ `rebind` carries the same overload, so `rebind(response_format=Model)` switches the output type to `BoundLLM[Model]`, `rebind(response_format=None)` switches it back to `BoundLLM[str]`, and leaving it out keeps the current type.
19
+ Every generate and stream method takes a conversation of `Message`s; a bare `str` is accepted as shorthand for a conversation of one `UserMessage` holding that text.
20
+
21
+ **The catalog is a constructor function per backend returning a ready LLM (anthropic adds a second for Bedrock).**
22
+ `from langchaint.openai import openai_model` then `openai_model("gpt-5.6-terra")`, and `from langchaint.anthropic import anthropic_model` then `anthropic_model("claude-sonnet-5")`, take the provider's own model identifier (a `Literal`, so typos fail the type check), look up the public prices, construct the adapter, and wrap it in an `LLM`.
23
+ `client=None` constructs the native first-party SDK client from environment credentials.
24
+ Anthropic Bedrock is a second sibling constructor, `anthropic_bedrock_model(model, aws_region=...)`: it names the same catalog model and reads the model's Bedrock surface (which of two SDK client classes) and wire model id from a table, so the application names neither the client class nor the Bedrock id.
25
+ Constructing an adapter directly covers models outside the catalog.
26
+
27
+ **The SDKs are optional dependencies, one extra per backend.**
28
+ The neutral core (`LLM`, the message tree, the error taxonomy) imports no SDK, so `import langchaint` needs neither package.
29
+ Each backend lives in its own subpackage that imports its SDK at module top: install `langchaint[openai]` or `langchaint[anthropic]` (or `langchaint[all]`) for the ones you use, and importing `langchaint.openai` without the openai package raises a `ModuleNotFoundError` naming the extra to install.
30
+ The import path is the boundary: only code that reaches for a backend requires its SDK, and a type checker following `langchaint.openai` never resolves anthropic's types.
31
+ OTel tracing follows the same pattern: `langchaint.tracing` imports only opentelemetry-api, install `langchaint[otel]`, and it stays off `import langchaint`.
32
+
33
+ **Adapters wrap SDK clients and delegate to the SDK.**
34
+ `AnthropicMessagesProvider(client=AsyncAnthropic(...))` and `OpenAIResponsesProvider(client=AsyncOpenAI(...))` call `messages.create/parse/stream` and `responses.create/parse/stream`; stream assembly and structured-output parsing are the SDK's, not hand-written.
35
+ Adapters store a `with_options(max_retries=0)` copy of the client, so the SDK never retries beneath the package's retry loop.
36
+ OpenAI support is the Responses API only: every supported OpenAI model speaks it, so there is no Chat Completions adapter.
37
+ The adapter always sends `store=False` because conversation state is the caller's conversation argument.
38
+ Anthropic Bedrock is two distinct SDK surfaces, the legacy `InvokeModel` client `AsyncAnthropicBedrock` and the Messages-API client `AsyncAnthropicBedrockMantle`, and the catalog models split across them, so `anthropic_bedrock_model` reads the client class and wire model id per model from `ANTHROPIC_BEDROCK`.
39
+ OpenAI on Bedrock is the bundled `AsyncBedrockOpenAI` passed as `client`; there is no Converse adapter.
40
+ Requests travel as typed frozen dataclasses whose optional fields are `X | Omit`, passed to the SDK as explicit keywords: no `**kwargs`, no hand-written wire TypedDicts, and the SDK overloads resolve without casts.
41
+
42
+ **Anthropic prompt caching is placed by the adapter.**
43
+ A bind-time `cache_control` marker goes on the system block (or the last tool when there is no system prompt), and a per-request marker goes on the last block of the last message, so the breakpoint follows a growing conversation.
44
+ Placement is manual because `messages.parse` lacks the top-level `cache_control` parameter; manual placement keeps create/parse/stream uniform.
45
+ Every marker's TTL is the adapter's `cache_ttl` (`"5m"` default, `"1h"` for entries that must survive longer gaps at 2x write cost), a keyword of `anthropic_model` and `anthropic_bedrock_model`.
46
+
47
+ **Usage counters partition, cost is the adapter's job.**
48
+ `Usage` stores `input_tokens_cache_read`, `input_tokens_cache_write`, `input_tokens_cache_none`, and `output_tokens`; the three input counters are a disjoint partition and `input_tokens_total` is derived.
49
+ `input_tokens_total_provider_reported` holds what the provider itself reported: openai's `input_tokens` includes cached and cache-write tokens, so the adapter fills it and a validator cross-checks the partition; anthropic reports no all-inclusive total, so it stays None.
50
+ The counters are validated non-negative, which is what guards the openai path: there the partition is derived by subtraction and sums to the reported total by construction.
51
+ `cost_in_usd` is computed inside the adapter from raw provider counts against a `PricingTable`, because providers split counters the normalized `Usage` collapses: anthropic bills 5-minute and 1-hour cache writes at different rates, and an adapter that sees 1-hour writes without `cache_write_1h_usd_per_million_tokens` raises `AbortBatchError` rather than misbill.
52
+
53
+ **Success is a `Response`, failure is a `GenerationError`.**
54
+ A generate that succeeds returns a frozen `Response[OutputT]` with every field present; one that ends terminally raises (or, in a batch, returns) a `GenerationError`.
55
+ Its three leaves are the terminal per-item outcomes: `RetriesExhaustedError` (transient budget spent), `RefusalError` (the model refused on the structured path), and `ExceededMaxCompletionTokensError` (the structured response hit the token cap before its JSON parsed).
56
+ On the base, `stop_reason` is `StopReason | None`: `"refusal"` or `"max_tokens"` on those two leaves, `None` on `RetriesExhaustedError`, whose attempts never reached a completed turn.
57
+ Both hold `attempt_records`, one `AttemptRecord` per request sent: raw `time.monotonic()` start and end readings bracketing the send only (slot waits and backoff sleeps excluded, so rate limiting is distinguishable from slow requests), the attempt's `TransientError` (None on the attempt that succeeded or a rejected 200), and the attempt's `usage`/`cost_in_usd` (None for a transport failure that billed nothing).
58
+ On a `Response` every record but the last failed; on a `GenerationError` the records describe the terminal outcome.
59
+ `attempts` is derived from the records on both.
60
+ Both also carry `model`, `provider_name`, and `elapsed_seconds` (first request to completion, waits included, so stored rather than derived), so the module-level `to_row(result)` flattens either to the same scalar keys and a mixed list of successes and failures is one table.
61
+ A `GenerationError` derives `usage`/`cost_in_usd` from its records, so a refusal or truncation reports its real cost while a retry-exhausted item whose attempts billed nothing reports zero; `error_text` carries the failure reason.
62
+ On a `Response`, `usage`, `cost_in_usd` (the successful attempt's own), `stop_reason`, `assistant_message`, and `raw` are always present.
63
+ `raw` is the SDK's own response model held by reference; it is never dumped to a dict, because dumping deep-copies every response body per request.
64
+
65
+ **One RateLimiter owns retrying and pacing.**
66
+ `RateLimiter` holds `max_attempts`, `backoff_base_seconds`, `backoff_max_seconds`, and `max_in_flight`; it is stateful and shareable, so one instance passed to several `LLM`s is one shared budget for the account they hit.
67
+ Its slot gates every request start on every path (first attempts, retries, batch items, stream openings); backoff sleeps outside the slot.
68
+ There is deliberately no `requests_per_minute`: an in-flight bound self-adjusts throughput along request duration, while a client-side rate number models one dimension of the provider's multi-dimensional limit and goes stale with the account tier.
69
+ A rate-limit error (`Provider.classify` returning `"rate_limit"`, or any error naming a server-stated retry-after) pauses admission for everyone sharing the limiter: for the server-stated wait when one was sent (parsed from the `retry-after-ms` / `retry-after` headers onto `TransientError.retry_after_seconds`, capped at 60 seconds), else for the failing task's backoff delay.
70
+ After the pause, admission stays limited to one probe request at a time until the probe succeeds; other transient errors (timeouts, 5xx) pause nobody, because they say nothing about the account's quota.
71
+
72
+ **Retry stays in the package, classification in the adapter.**
73
+ Adapters raise `TransientError`, `AbortBatchError`, or a `GenerationError` leaf directly where they know the answer: rate limits and overload (transient), a bad request (abort), and an empty structured parse, which splits three ways (a refusal is `RefusalError`, a token-cap truncation is `ExceededMaxCompletionTokensError`, anything else is `TransientError`).
74
+ The retry loop retries only `TransientError`; it honors the rest without asking.
75
+ Unrecognized exceptions go through `Provider.classify`, which returns `"rate_limit"`, `"transient"`, or `"abort"`, and anything it does not recognize is abort.
76
+ `generate_one` raises `RetriesExhaustedError` for transient exhaustion, `RefusalError` or `ExceededMaxCompletionTokensError` on the structured path, and `AbortBatchError` for an abort classification; the first three share the `GenerationError` base a caller can catch at once.
77
+ `generate_many` returns `list[Response[OutputT] | GenerationError]`, so a terminal per-item failure is a row in its slot rather than a raise, while an `AbortBatchError` still cancels the siblings and raises; results stay order-aligned.
78
+
79
+ **Streaming is a handle.**
80
+ `stream_one` returns a `StreamHandle`: an async iterator of `StreamItem = str | ToolCall`, an idempotent `await handle.final()` returning the assembled `Response`, and an async context manager so abandoning a stream closes the connection.
81
+ Text chunks are the provider SDK's own strings passed through without a wrapper class or copy, and each `ToolCall` is yielded once, complete, when its block closes.
82
+ There are deliberately no tool-call delta items (a consumer cannot act on partial argument JSON, and both SDKs accumulate the arguments and hand over the finished call) and no usage or stop items (usage, `cost_in_usd`, and `stop_reason` live on `final()`'s `Response`, and a bare-`str` stop reason could not share a union with bare-`str` text chunks).
83
+ Connection failures before the first yielded item retry under the `RateLimiter`; after the first yielded item nothing retries, because replaying chunks the caller already consumed would duplicate output.
84
+ An open stream holds one `RateLimiter` slot from opening until it closes or exhausts, so long-lived streams count against `max_in_flight`.
85
+
86
+ **Tools are explicit, dispatch is owned.**
87
+ A `Tool` is an async function taking one pydantic model and returning str or a sequence of content parts (text and images the model then sees), plus an explicit name and description; the args model is the schema source, so there is no signature introspection and no docstring scraping.
88
+ Tool content is model-facing, so it is exactly `MessageContent` (`str | Sequence[Part]`, the model-facing message body, aliased in `messages.py`): a function with a typed result serializes it to that form itself.
89
+ A function may instead return a `ToolOutputExplicit` wrapping that content plus `is_error` and `app_data` (the app-facing channel, which does carry a typed `BaseModel` or `Mapping` the model never sees).
90
+ `Tool.validate_and_run` validates raw call JSON against `args_model` and runs the function; it lives on `Tool` because there the args type parameter is concrete, so the validated arguments reach the function fully typed.
91
+ `Tool.dispatch` returns `DispatchHandled[AppDataT] | DispatchInvalidToolArgs`, so on the handled outcome a caller that dispatched a known tool reads `app_data` back at its concrete type with no `isinstance`.
92
+ `ToolManager` serializes the tools provider-neutrally, resolves the called name, and returns each outcome as a `DispatchOutcome`, a three-arm union over a heterogeneous tool set (where `app_data` erases to `BaseModel | Mapping[str, object] | None`): `DispatchHandled` carries the model-facing `ToolMessage` the caller appends to the conversation, paired with the function's `app_data` (data the model never sees); `DispatchInvalidToolArgs` carries a default `is_error` `ToolMessage` plus the pydantic `ValidationError` as a required field, so a caller that authors its own reply reads `validation_error.errors()` with no narrowing crutch; `DispatchUnknownTool` carries a default `is_error` `ToolMessage` naming the held tools plus the off-list `called_name`.
93
+ Every arm carries `tool_message`, so a caller that only appends the reply reads `result.tool_message` with no `match`.
94
+ An off-list tool name becomes a `DispatchUnknownTool` outcome rather than a raise, just like argument JSON the model got wrong becomes a `DispatchInvalidToolArgs` outcome: both are model data the model can correct (a provider can emit a name outside the sent schemas, and a rebind can strand an earlier turn's `tool_call`).
95
+ `tool_choice` is the neutral vocabulary `"auto" | "required" | SpecificTool | "none"` (anthropic's `"any"` maps from `"required"`); `parallel_tool_calls: bool` maps to anthropic `disable_parallel_tool_use` and openai `parallel_tool_calls`.
96
+
97
+ **The ReAct loop is a recipe, not vendored code.**
98
+ There is no agent class.
99
+ The loop is ~15 lines and is correct because the hard parts (retries, pacing, classification, validation) live below `generate_one` and `dispatch`, so a hand-copied loop cannot be subtly wrong.
100
+ The non-streaming loop runs over `generate_one`; the streaming loop runs over `stream_one`, printing text chunks as they arrive and dispatching the completed `ToolCall`s between turns, reading `dispatch(call).tool_message` for the ToolMessage it appends and matching `DispatchHandled` for the function's model-invisible `app_data` or `DispatchInvalidToolArgs` for the `validation_error`.
101
+ Owning the loop is what lets a caller enforce a budget mid-run, stream tokens, or swap the binding on a tool result, none of which a fixed agent surface exposes.
102
+
103
+ ## Layout
104
+
105
+ CLAUDE.md design, naming, and review rules
106
+ README.md this file
107
+ src/langchaint/
108
+ messages.py message and part models, StopReason
109
+ usage.py Usage counters
110
+ inference_params.py InferenceParams
111
+ exceptions.py error vocabulary: TransientError,
112
+ AbortBatchError, AttemptRecord,
113
+ GenerationError and its leaves
114
+ response.py Response[OutputT], to_row
115
+ rate_limiter.py RateLimiter
116
+ tools.py Tool, ToolSchema, ToolManager
117
+ provider.py Binding, ToolChoice, PricingTable,
118
+ StreamItem, ProviderResult,
119
+ ProviderStream, BoundProvider, Provider
120
+ streaming.py StreamHandle
121
+ llm.py LLM, BoundLLM, the retry loop
122
+ anthropic/ the anthropic backend (needs the anthropic
123
+ SDK): __init__ has anthropic_model,
124
+ ANTHROPIC_PRICING, AnthropicModelName;
125
+ messages_provider.py has the adapter
126
+ AnthropicMessagesProvider
127
+ openai/ the openai backend (needs the openai SDK):
128
+ __init__ has openai_model, OPENAI_PRICING,
129
+ OpenAIModelName; responses_provider.py has
130
+ the adapter OpenAIResponsesProvider
131
+ tracing/ OTel span tracing (needs opentelemetry-api,
132
+ install langchaint[otel]): __init__ has
133
+ TracedLLM, TracedBoundLLM, TracedStreamHandle
134
+
135
+ ## Verification
136
+
137
+ Run `scripts/CI.sh`; it runs `pyrefly check`, `ruff check`, and `pytest` through `uv run`, so the tools resolve from the locked dev dependency group, not a hand-activated `.venv`.
138
+ All must pass with zero errors.
139
+ The tests are offline: they feed constructed SDK objects into the adapter helpers and drive `BoundLLM`/`StreamHandle` with stub providers, so they need no API keys.
@@ -0,0 +1,203 @@
1
+ [project]
2
+ name = "langchaint"
3
+ version = "0.3.0"
4
+ description = "It ain't langchain."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "John Hopfensperger" }]
9
+ requires-python = ">=3.13"
10
+ keywords = ["llm", "anthropic", "openai", "claude", "async"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "pydantic>=2.13.4",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/s-banach/langchaint"
23
+ Repository = "https://github.com/s-banach/langchaint"
24
+ Issues = "https://github.com/s-banach/langchaint/issues"
25
+
26
+ [project.optional-dependencies]
27
+ # One extra per backend: the langchaint.anthropic / langchaint.openai subpackages
28
+ # import their SDK at module top, so an application installs only the backends it uses.
29
+ anthropic = ["anthropic>=0.116.0"]
30
+ openai = ["openai>=2.45.0"]
31
+ all = ["anthropic>=0.116.0", "openai>=2.45.0"]
32
+ # The langchaint.tracing subpackage imports only opentelemetry-api (trace, Tracer,
33
+ # Status, StatusCode); a production app installs the api and wires its own SDK,
34
+ # matching OTel's api/sdk split. Deliberately not folded into all, which stays
35
+ # backends-only: an app that wants both SDKs but no tracing must not pull OTel.
36
+ otel = ["opentelemetry-api>=1.30.0"]
37
+
38
+ [build-system]
39
+ requires = ["uv_build>=0.11.26,<0.12.0"]
40
+ build-backend = "uv_build"
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "langchaint[all]",
45
+ "langchaint[otel]",
46
+ # opentelemetry-sdk is needed only by the tracing tests' in-memory exporter
47
+ # (InMemorySpanExporter, SimpleSpanProcessor, TracerProvider); the wrapper
48
+ # itself imports only opentelemetry-api.
49
+ "opentelemetry-sdk>=1.30.0",
50
+ "pytest>=9.0.2",
51
+ "pyrefly>=1.2.0.dev2",
52
+ "ruff>=0.15.20",
53
+ "typos>=1.48.0",
54
+ ]
55
+
56
+ [tool.pyrefly]
57
+ preset = "strict"
58
+ # project-includes bounds type checking to exactly these trees (leaving the developer
59
+ # tooling under scripts/ out); search-path is the separate import-resolution setting, "src"
60
+ # being the langchaint import root.
61
+ project-includes = ["src", "tests", "examples"]
62
+ # src is the langchaint import root; the repo root is added so cross-module test
63
+ # imports (tests.test_bound_llm reused by tests.test_tracing) resolve the same way
64
+ # pytest resolves them at runtime.
65
+ search-path = ["src", "."]
66
+
67
+ # Enabled above strict: redundant-cast enforces the "no cast" smell's
68
+ # stale half (every finding is a deletable cast), and deprecated flags
69
+ # stdlib/library deprecations before an upgrade breaks them.
70
+ # explicit-any stays off: the remaining Any sites are deliberate
71
+ # existential typing (overload implementations, SDK stream generics,
72
+ # the heterogeneous Tool collection), and suppressions on deliberate
73
+ # Any would only add noise. When pyrefly 1.2.0 lands, also enable
74
+ # implicit-any-variable (inference gaps that silently make a variable
75
+ # Any): the tree is clean for it since Tool.validate_and_run owns the
76
+ # typed validate-then-call pair, but 1.1.1 rejects the rule name.
77
+ [tool.pyrefly.errors]
78
+ redundant-cast = "error"
79
+ deprecated = "error"
80
+
81
+ [tool.pytest.ini_options]
82
+ testpaths = ["tests"]
83
+
84
+ [tool.ruff]
85
+ line-length = 99
86
+ format.preview = true
87
+
88
+ lint.preview = true
89
+ lint.explicit-preview-rules = true
90
+ lint.select = [
91
+ "ANN",
92
+ "BLE",
93
+ "FBT",
94
+ "PERF",
95
+ "A",
96
+ "B",
97
+ "INP",
98
+ "PIE",
99
+ "PT",
100
+ "RET",
101
+ "RSE",
102
+ "SLF",
103
+ "SIM",
104
+ "SLOT",
105
+ "TID",
106
+ "TC",
107
+ "ARG",
108
+ "LOG",
109
+ "G",
110
+ "PTH",
111
+ "FLY",
112
+ "I",
113
+ "N",
114
+ "F",
115
+ "PL",
116
+ "UP",
117
+ "FURB",
118
+ "RUF",
119
+ "TRY",
120
+ "W505", # doc-line-too-long, capped by pycodestyle.max-doc-length below
121
+ # Docstrings, selected as exact codes with no pydocstyle convention.
122
+ # The dropped google convention was an ignore-overlay whose removals
123
+ # the code satisfies anyway, and it ignored D401, which then needed an
124
+ # exact-code select to win over the convention. Deliberately absent:
125
+ # D203 and D213, which conflict with D211 and D212; D405-D414 and
126
+ # D416, which enforce underlined rst/numpy section headers instead of
127
+ # the Args:/Raises:/Yields: prose sections used here; and the preview
128
+ # rules D420 (incorrect-section-order) and D421
129
+ # (property-docstring-starts-with-verb), not opted into: D421 flags a
130
+ # verb-first property docstring, which the imperative mood used
131
+ # everywhere here (D401) would otherwise produce.
132
+ "D100", # undocumented-public-module
133
+ "D101", # undocumented-public-class
134
+ "D102", # undocumented-public-method
135
+ "D103", # undocumented-public-function
136
+ "D104", # undocumented-public-package
137
+ "D105", # undocumented-magic-method
138
+ "D106", # undocumented-public-nested-class
139
+ "D107", # undocumented-public-init
140
+ "D200", # unnecessary-multiline-docstring
141
+ "D201", # blank-line-before-function
142
+ "D202", # blank-line-after-function
143
+ "D204", # incorrect-blank-line-after-class
144
+ "D205", # missing-blank-line-after-summary
145
+ "D206", # docstring-tab-indentation
146
+ "D207", # under-indentation
147
+ "D208", # over-indentation
148
+ "D209", # new-line-after-last-paragraph
149
+ "D210", # surrounding-whitespace
150
+ "D211", # blank-line-before-class
151
+ "D212", # multi-line-summary-first-line
152
+ "D214", # overindented-section
153
+ "D215", # overindented-section-underline
154
+ "D300", # triple-single-quotes
155
+ "D301", # escape-sequence-in-docstring
156
+ "D400", # missing-trailing-period
157
+ "D401", # non-imperative-mood
158
+ "D402", # signature-in-docstring
159
+ "D403", # first-word-uncapitalized
160
+ "D404", # docstring-starts-with-this
161
+ "D415", # missing-terminal-punctuation
162
+ "D417", # undocumented-param
163
+ "D418", # overload-with-docstring
164
+ "D419", # empty-docstring
165
+ # Preview docstring features
166
+ "DOC202", # docstring documents a return that does not exist
167
+ "DOC402", # missing "Yields" in docstring
168
+ "DOC403", # docstring documents a yield that does not exist
169
+ # DOC501 (raised exception missing) and DOC502 (documents an exception not
170
+ # raised) are deliberately absent. Both resolve a raise only for the literal
171
+ # `raise SomeError(...)` / `raise SomeError` shapes; they cannot see through
172
+ # `raise type(exc)(...)` (re-raising the same leaf class enriched) or
173
+ # `raise Class.factory(...)` (they read the last attribute, `factory`, as the
174
+ # exception name), both of which this package uses. Under them a correctly
175
+ # documented raise reports as extraneous and a real raise reports as missing,
176
+ # so documenting raises is a CLAUDE.md convention (see "Document what a
177
+ # function raises") checked in review instead.
178
+ ]
179
+ lint.ignore = [
180
+ "ANN401",
181
+ "SIM105",
182
+ "PLR2004",
183
+ "TRY003",
184
+ ]
185
+
186
+ # Docstrings and comments break lines at sentence boundaries, not at a column
187
+ # width; a sentence longer than this cap breaks at a clause boundary. Code
188
+ # stays at line-length 99 via the formatter; E501 is deliberately unselected.
189
+ lint.pycodestyle.max-doc-length = 120
190
+ lint.flake8-tidy-imports.ban-relative-imports = "all"
191
+ lint.pylint.max-args = 7
192
+
193
+ # The tests exercise private helpers and attributes directly (Provider._request,
194
+ # BoundLLM._bound_provider); those accesses are the unit under test, so SLF001
195
+ # (private-member-access) cannot be satisfied without exposing internals.
196
+ lint.per-file-ignores."tests/*" = ["SLF001"]
197
+
198
+ # The examples are standalone scripts run by path (python examples/01_basics.py),
199
+ # deliberately not an importable package, so INP001 (implicit-namespace-package,
200
+ # which would demand an examples/__init__.py) does not apply.
201
+ lint.per-file-ignores."examples/*" = ["INP001"]
202
+
203
+ [tool.typos.default.extend-words]
@@ -0,0 +1,98 @@
1
+ """langchaint: a provider-neutral LLM client.
2
+
3
+ Adapters wrap the official anthropic/openai SDK clients; generation happens only through LLM.bind(...) -> BoundLLM.
4
+ """
5
+
6
+ from langchaint.exceptions import (
7
+ AbortBatchError,
8
+ AttemptRecord,
9
+ ExceededMaxCompletionTokensError,
10
+ GenerationError,
11
+ InvalidToolArgsError,
12
+ RefusalError,
13
+ RetriesExhaustedError,
14
+ StreamProtocolError,
15
+ TransientError,
16
+ )
17
+ from langchaint.inference_params import InferenceParams, ReasoningEffort
18
+ from langchaint.llm import LLM, BoundLLM
19
+ from langchaint.messages import (
20
+ AssistantMessage,
21
+ ImagePart,
22
+ Message,
23
+ MessageContent,
24
+ Part,
25
+ ReasoningTrace,
26
+ StopReason,
27
+ TextPart,
28
+ ToolCall,
29
+ ToolMessage,
30
+ TurnElement,
31
+ UserMessage,
32
+ )
33
+ from langchaint.provider import (
34
+ PricingTable,
35
+ SpecificTool,
36
+ StreamItem,
37
+ ToolChoice,
38
+ )
39
+ from langchaint.rate_limiter import RateLimiter
40
+ from langchaint.response import Response, RowValue, to_row
41
+ from langchaint.streaming import StreamHandle
42
+ from langchaint.tools import (
43
+ DispatchHandled,
44
+ DispatchInvalidToolArgs,
45
+ DispatchOutcome,
46
+ DispatchUnknownTool,
47
+ Tool,
48
+ ToolManager,
49
+ ToolOutput,
50
+ ToolOutputExplicit,
51
+ )
52
+ from langchaint.usage import Usage
53
+
54
+ __all__ = [
55
+ "LLM",
56
+ "AbortBatchError",
57
+ "AssistantMessage",
58
+ "AttemptRecord",
59
+ "BoundLLM",
60
+ "DispatchHandled",
61
+ "DispatchInvalidToolArgs",
62
+ "DispatchOutcome",
63
+ "DispatchUnknownTool",
64
+ "ExceededMaxCompletionTokensError",
65
+ "GenerationError",
66
+ "ImagePart",
67
+ "InferenceParams",
68
+ "InvalidToolArgsError",
69
+ "Message",
70
+ "MessageContent",
71
+ "Part",
72
+ "PricingTable",
73
+ "RateLimiter",
74
+ "ReasoningEffort",
75
+ "ReasoningTrace",
76
+ "RefusalError",
77
+ "Response",
78
+ "RetriesExhaustedError",
79
+ "RowValue",
80
+ "SpecificTool",
81
+ "StopReason",
82
+ "StreamHandle",
83
+ "StreamItem",
84
+ "StreamProtocolError",
85
+ "TextPart",
86
+ "Tool",
87
+ "ToolCall",
88
+ "ToolChoice",
89
+ "ToolManager",
90
+ "ToolMessage",
91
+ "ToolOutput",
92
+ "ToolOutputExplicit",
93
+ "TransientError",
94
+ "TurnElement",
95
+ "Usage",
96
+ "UserMessage",
97
+ "to_row",
98
+ ]