llm-seam 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llm_seam-0.1.0/LICENSE +21 -0
- llm_seam-0.1.0/PKG-INFO +146 -0
- llm_seam-0.1.0/README.md +116 -0
- llm_seam-0.1.0/llm_seam/__init__.py +37 -0
- llm_seam-0.1.0/llm_seam/client.py +126 -0
- llm_seam-0.1.0/llm_seam/exceptions.py +33 -0
- llm_seam-0.1.0/llm_seam/fallback.py +70 -0
- llm_seam-0.1.0/llm_seam/providers/__init__.py +24 -0
- llm_seam-0.1.0/llm_seam/providers/agent_sdk.py +360 -0
- llm_seam-0.1.0/llm_seam/providers/anthropic.py +92 -0
- llm_seam-0.1.0/llm_seam/providers/base.py +28 -0
- llm_seam-0.1.0/llm_seam/providers/claude_cli.py +169 -0
- llm_seam-0.1.0/llm_seam/providers/mock.py +46 -0
- llm_seam-0.1.0/llm_seam/providers/ollama.py +76 -0
- llm_seam-0.1.0/llm_seam/providers/openai.py +91 -0
- llm_seam-0.1.0/llm_seam/retry.py +77 -0
- llm_seam-0.1.0/llm_seam/tool_guards.py +155 -0
- llm_seam-0.1.0/llm_seam.egg-info/PKG-INFO +146 -0
- llm_seam-0.1.0/llm_seam.egg-info/SOURCES.txt +24 -0
- llm_seam-0.1.0/llm_seam.egg-info/dependency_links.txt +1 -0
- llm_seam-0.1.0/llm_seam.egg-info/requires.txt +12 -0
- llm_seam-0.1.0/llm_seam.egg-info/top_level.txt +1 -0
- llm_seam-0.1.0/pyproject.toml +52 -0
- llm_seam-0.1.0/setup.cfg +4 -0
- llm_seam-0.1.0/tests/test_agent_sdk_provider.py +498 -0
- llm_seam-0.1.0/tests/test_tool_guards.py +171 -0
llm_seam-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 m0j0d
|
|
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.
|
llm_seam-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llm-seam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unified LLM abstraction with provider pattern for portfolio projects
|
|
5
|
+
Author-email: Mike Donnelly <82827803+m0j0d@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/m0j0d/libs/tree/main/llm-seam
|
|
8
|
+
Project-URL: Repository, https://github.com/m0j0d/libs
|
|
9
|
+
Project-URL: Issues, https://github.com/m0j0d/libs/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: anthropic>=0.122.0
|
|
21
|
+
Requires-Dist: openai>=3.1.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=1.4.0; extra == "dev"
|
|
25
|
+
Provides-Extra: sdk
|
|
26
|
+
Requires-Dist: claude-agent-sdk>=0.2.139; extra == "sdk"
|
|
27
|
+
Provides-Extra: ollama
|
|
28
|
+
Requires-Dist: litellm>=1.96.2; extra == "ollama"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# llm-seam
|
|
32
|
+
|
|
33
|
+
Unified LLM abstraction with a provider pattern — one interface, five backends,
|
|
34
|
+
automatic fallback and retry.
|
|
35
|
+
|
|
36
|
+
**Note on the name:** this package was `llm-base` until 2026-08-21. It was
|
|
37
|
+
renamed to `llm-seam` because the PyPI name `llm-base` belongs to an
|
|
38
|
+
unrelated, older project — no relation to this package.
|
|
39
|
+
|
|
40
|
+
## What's in the box
|
|
41
|
+
|
|
42
|
+
| Module | What it gives you |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `llm_seam.client` | `LLMClient` — auto-selects a provider from the environment (Anthropic, then OpenAI, then a mock fallback), or accepts an explicit provider name/instance |
|
|
45
|
+
| `llm_seam.providers.base` | `LLMProvider` — the `Protocol` every provider implements (`complete`, `get_name`, `supports_streaming`) |
|
|
46
|
+
| `llm_seam.providers.anthropic` | `AnthropicProvider` — wraps the `anthropic` SDK |
|
|
47
|
+
| `llm_seam.providers.openai` | `OpenAIProvider` — wraps the `openai` SDK |
|
|
48
|
+
| `llm_seam.providers.claude_cli` | `ClaudeCLIProvider` — shells out to the Claude Code CLI |
|
|
49
|
+
| `llm_seam.providers.agent_sdk` | `AgentSDKProvider` — wraps `claude-agent-sdk` (requires the `[sdk]` extra); supports an optional `can_use_tool` security callback (see below) |
|
|
50
|
+
| `llm_seam.providers.ollama` | `OllamaProvider` — local models via `litellm` (requires the `[ollama]` extra) |
|
|
51
|
+
| `llm_seam.providers.mock` | `MockProvider` — fixed-response provider for tests and offline use |
|
|
52
|
+
| `llm_seam.fallback` | `FallbackChain` — tries a list of providers in order, raises `AllProvidersFailed` only if every one of them fails |
|
|
53
|
+
| `llm_seam.retry` | `with_retry` — decorator with exponential backoff, retries on `RateLimitError` by default (configurable) |
|
|
54
|
+
| `llm_seam.tool_guards` | `path_scoped_tool_guard` — ready-made `can_use_tool` callback for `AgentSDKProvider`; confines a tool's path arguments to an allowlist of directories, resists `..` traversal, symlink escapes, and absolute paths outside the allowlist |
|
|
55
|
+
| `llm_seam.exceptions` | `LLMError` (base), `RateLimitError`, `TokenLimitError`, `TimeoutError`, `AllProvidersFailed` |
|
|
56
|
+
|
|
57
|
+
## Design
|
|
58
|
+
|
|
59
|
+
**One interface, provider-agnostic callers.** `LLMClient` and every provider
|
|
60
|
+
implement the same three-method `LLMProvider` protocol (`complete`,
|
|
61
|
+
`get_name`, `supports_streaming`), so a caller can swap Anthropic for a local
|
|
62
|
+
Ollama model, the Claude CLI, or a mock in tests without touching call sites.
|
|
63
|
+
|
|
64
|
+
**Auto-selection is a convenience, not a requirement.** `LLMClient()` with no
|
|
65
|
+
arguments picks Anthropic if `ANTHROPIC_API_KEY` is set, else OpenAI if
|
|
66
|
+
`OPENAI_API_KEY` is set, else falls back to `MockProvider`. Pass an explicit
|
|
67
|
+
`provider=` name or instance to bypass auto-selection entirely.
|
|
68
|
+
|
|
69
|
+
**Provider-name convention.** When constructing by name string, this
|
|
70
|
+
portfolio distinguishes two Claude transports: `"claude-sdk"` selects
|
|
71
|
+
`AgentSDKProvider` (the `claude-agent-sdk` package, preferred for new
|
|
72
|
+
scheduled work — see `libs/CLAUDE.md`), and `"claude-cli"` selects
|
|
73
|
+
`ClaudeCLIProvider` (shells out to the installed Claude Code CLI). `"claude-sdk"`
|
|
74
|
+
is only registered as a valid name if `claude-agent-sdk` is installed (the
|
|
75
|
+
`[sdk]` extra); otherwise constructing by that name raises `ValueError`.
|
|
76
|
+
|
|
77
|
+
**`AgentSDKProvider`'s `can_use_tool` is a security boundary, not a hygiene
|
|
78
|
+
knob.** It threads straight to `ClaudeAgentOptions.can_use_tool`. If a caller
|
|
79
|
+
passes a callback and the installed `claude-agent-sdk` can't accept the
|
|
80
|
+
field, the call raises `LLMError` rather than silently proceeding ungated —
|
|
81
|
+
a caller that believes tool calls are being permission-checked must never
|
|
82
|
+
find out otherwise the hard way. Use
|
|
83
|
+
`tool_guards.path_scoped_tool_guard(allowed_dirs)` rather than hand-rolling
|
|
84
|
+
path checks.
|
|
85
|
+
|
|
86
|
+
**Fallback and retry compose, they don't replace each other.** `FallbackChain`
|
|
87
|
+
moves between *providers*; `with_retry` retries a single call on a
|
|
88
|
+
*transient* error (rate limits by default). Wrap a `FallbackChain`'s
|
|
89
|
+
`.complete` in `with_retry`, or use either alone.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install llm-seam
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Optional extras:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install "llm-seam[sdk]" # AgentSDKProvider (claude-agent-sdk)
|
|
101
|
+
pip install "llm-seam[ollama]" # OllamaProvider (litellm)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from llm_seam import LLMClient, FallbackChain, with_retry
|
|
108
|
+
|
|
109
|
+
# Auto-select a provider from the environment
|
|
110
|
+
client = LLMClient()
|
|
111
|
+
response = client.complete("Hello, world!")
|
|
112
|
+
|
|
113
|
+
# Explicit provider by name
|
|
114
|
+
client = LLMClient(provider="anthropic")
|
|
115
|
+
|
|
116
|
+
# Fall back through providers in order
|
|
117
|
+
chain = FallbackChain(providers=[
|
|
118
|
+
LLMClient(provider="claude-cli").provider,
|
|
119
|
+
LLMClient(provider="anthropic").provider,
|
|
120
|
+
])
|
|
121
|
+
response = chain.complete("Hello")
|
|
122
|
+
|
|
123
|
+
# Retry a call with exponential backoff on rate limits
|
|
124
|
+
@with_retry(max_retries=3, initial_delay=1.0)
|
|
125
|
+
def call():
|
|
126
|
+
return client.complete("Hello")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Testing
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cd llm-seam
|
|
133
|
+
pytest tests/
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Dependencies
|
|
137
|
+
|
|
138
|
+
- `anthropic>=0.122.0`
|
|
139
|
+
- `openai>=3.1.0`
|
|
140
|
+
- `claude-agent-sdk>=0.2.139` (optional, `[sdk]` extra)
|
|
141
|
+
- `litellm>=1.96.2` (optional, `[ollama]` extra — pinned floor to avoid a
|
|
142
|
+
supply-chain attack affecting 1.82.7–1.82.8)
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
llm_seam-0.1.0/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# llm-seam
|
|
2
|
+
|
|
3
|
+
Unified LLM abstraction with a provider pattern — one interface, five backends,
|
|
4
|
+
automatic fallback and retry.
|
|
5
|
+
|
|
6
|
+
**Note on the name:** this package was `llm-base` until 2026-08-21. It was
|
|
7
|
+
renamed to `llm-seam` because the PyPI name `llm-base` belongs to an
|
|
8
|
+
unrelated, older project — no relation to this package.
|
|
9
|
+
|
|
10
|
+
## What's in the box
|
|
11
|
+
|
|
12
|
+
| Module | What it gives you |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `llm_seam.client` | `LLMClient` — auto-selects a provider from the environment (Anthropic, then OpenAI, then a mock fallback), or accepts an explicit provider name/instance |
|
|
15
|
+
| `llm_seam.providers.base` | `LLMProvider` — the `Protocol` every provider implements (`complete`, `get_name`, `supports_streaming`) |
|
|
16
|
+
| `llm_seam.providers.anthropic` | `AnthropicProvider` — wraps the `anthropic` SDK |
|
|
17
|
+
| `llm_seam.providers.openai` | `OpenAIProvider` — wraps the `openai` SDK |
|
|
18
|
+
| `llm_seam.providers.claude_cli` | `ClaudeCLIProvider` — shells out to the Claude Code CLI |
|
|
19
|
+
| `llm_seam.providers.agent_sdk` | `AgentSDKProvider` — wraps `claude-agent-sdk` (requires the `[sdk]` extra); supports an optional `can_use_tool` security callback (see below) |
|
|
20
|
+
| `llm_seam.providers.ollama` | `OllamaProvider` — local models via `litellm` (requires the `[ollama]` extra) |
|
|
21
|
+
| `llm_seam.providers.mock` | `MockProvider` — fixed-response provider for tests and offline use |
|
|
22
|
+
| `llm_seam.fallback` | `FallbackChain` — tries a list of providers in order, raises `AllProvidersFailed` only if every one of them fails |
|
|
23
|
+
| `llm_seam.retry` | `with_retry` — decorator with exponential backoff, retries on `RateLimitError` by default (configurable) |
|
|
24
|
+
| `llm_seam.tool_guards` | `path_scoped_tool_guard` — ready-made `can_use_tool` callback for `AgentSDKProvider`; confines a tool's path arguments to an allowlist of directories, resists `..` traversal, symlink escapes, and absolute paths outside the allowlist |
|
|
25
|
+
| `llm_seam.exceptions` | `LLMError` (base), `RateLimitError`, `TokenLimitError`, `TimeoutError`, `AllProvidersFailed` |
|
|
26
|
+
|
|
27
|
+
## Design
|
|
28
|
+
|
|
29
|
+
**One interface, provider-agnostic callers.** `LLMClient` and every provider
|
|
30
|
+
implement the same three-method `LLMProvider` protocol (`complete`,
|
|
31
|
+
`get_name`, `supports_streaming`), so a caller can swap Anthropic for a local
|
|
32
|
+
Ollama model, the Claude CLI, or a mock in tests without touching call sites.
|
|
33
|
+
|
|
34
|
+
**Auto-selection is a convenience, not a requirement.** `LLMClient()` with no
|
|
35
|
+
arguments picks Anthropic if `ANTHROPIC_API_KEY` is set, else OpenAI if
|
|
36
|
+
`OPENAI_API_KEY` is set, else falls back to `MockProvider`. Pass an explicit
|
|
37
|
+
`provider=` name or instance to bypass auto-selection entirely.
|
|
38
|
+
|
|
39
|
+
**Provider-name convention.** When constructing by name string, this
|
|
40
|
+
portfolio distinguishes two Claude transports: `"claude-sdk"` selects
|
|
41
|
+
`AgentSDKProvider` (the `claude-agent-sdk` package, preferred for new
|
|
42
|
+
scheduled work — see `libs/CLAUDE.md`), and `"claude-cli"` selects
|
|
43
|
+
`ClaudeCLIProvider` (shells out to the installed Claude Code CLI). `"claude-sdk"`
|
|
44
|
+
is only registered as a valid name if `claude-agent-sdk` is installed (the
|
|
45
|
+
`[sdk]` extra); otherwise constructing by that name raises `ValueError`.
|
|
46
|
+
|
|
47
|
+
**`AgentSDKProvider`'s `can_use_tool` is a security boundary, not a hygiene
|
|
48
|
+
knob.** It threads straight to `ClaudeAgentOptions.can_use_tool`. If a caller
|
|
49
|
+
passes a callback and the installed `claude-agent-sdk` can't accept the
|
|
50
|
+
field, the call raises `LLMError` rather than silently proceeding ungated —
|
|
51
|
+
a caller that believes tool calls are being permission-checked must never
|
|
52
|
+
find out otherwise the hard way. Use
|
|
53
|
+
`tool_guards.path_scoped_tool_guard(allowed_dirs)` rather than hand-rolling
|
|
54
|
+
path checks.
|
|
55
|
+
|
|
56
|
+
**Fallback and retry compose, they don't replace each other.** `FallbackChain`
|
|
57
|
+
moves between *providers*; `with_retry` retries a single call on a
|
|
58
|
+
*transient* error (rate limits by default). Wrap a `FallbackChain`'s
|
|
59
|
+
`.complete` in `with_retry`, or use either alone.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install llm-seam
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Optional extras:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install "llm-seam[sdk]" # AgentSDKProvider (claude-agent-sdk)
|
|
71
|
+
pip install "llm-seam[ollama]" # OllamaProvider (litellm)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from llm_seam import LLMClient, FallbackChain, with_retry
|
|
78
|
+
|
|
79
|
+
# Auto-select a provider from the environment
|
|
80
|
+
client = LLMClient()
|
|
81
|
+
response = client.complete("Hello, world!")
|
|
82
|
+
|
|
83
|
+
# Explicit provider by name
|
|
84
|
+
client = LLMClient(provider="anthropic")
|
|
85
|
+
|
|
86
|
+
# Fall back through providers in order
|
|
87
|
+
chain = FallbackChain(providers=[
|
|
88
|
+
LLMClient(provider="claude-cli").provider,
|
|
89
|
+
LLMClient(provider="anthropic").provider,
|
|
90
|
+
])
|
|
91
|
+
response = chain.complete("Hello")
|
|
92
|
+
|
|
93
|
+
# Retry a call with exponential backoff on rate limits
|
|
94
|
+
@with_retry(max_retries=3, initial_delay=1.0)
|
|
95
|
+
def call():
|
|
96
|
+
return client.complete("Hello")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Testing
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
cd llm-seam
|
|
103
|
+
pytest tests/
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Dependencies
|
|
107
|
+
|
|
108
|
+
- `anthropic>=0.122.0`
|
|
109
|
+
- `openai>=3.1.0`
|
|
110
|
+
- `claude-agent-sdk>=0.2.139` (optional, `[sdk]` extra)
|
|
111
|
+
- `litellm>=1.96.2` (optional, `[ollama]` extra — pinned floor to avoid a
|
|
112
|
+
supply-chain attack affecting 1.82.7–1.82.8)
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# llm-seam: Unified LLM abstraction with provider pattern
|
|
2
|
+
|
|
3
|
+
from .client import LLMClient
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
LLMError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
TokenLimitError,
|
|
8
|
+
TimeoutError,
|
|
9
|
+
AllProvidersFailed,
|
|
10
|
+
)
|
|
11
|
+
from .providers.base import LLMProvider
|
|
12
|
+
from .providers.mock import MockProvider
|
|
13
|
+
from .providers.anthropic import AnthropicProvider
|
|
14
|
+
from .providers.openai import OpenAIProvider
|
|
15
|
+
from .providers.claude_cli import ClaudeCLIProvider
|
|
16
|
+
from .providers.ollama import OllamaProvider
|
|
17
|
+
from .fallback import FallbackChain
|
|
18
|
+
from .retry import with_retry
|
|
19
|
+
from .tool_guards import path_scoped_tool_guard
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"LLMClient",
|
|
23
|
+
"LLMProvider",
|
|
24
|
+
"MockProvider",
|
|
25
|
+
"AnthropicProvider",
|
|
26
|
+
"OpenAIProvider",
|
|
27
|
+
"ClaudeCLIProvider",
|
|
28
|
+
"OllamaProvider",
|
|
29
|
+
"FallbackChain",
|
|
30
|
+
"with_retry",
|
|
31
|
+
"path_scoped_tool_guard",
|
|
32
|
+
"LLMError",
|
|
33
|
+
"RateLimitError",
|
|
34
|
+
"TokenLimitError",
|
|
35
|
+
"TimeoutError",
|
|
36
|
+
"AllProvidersFailed",
|
|
37
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Main LLMClient entry point with auto-provider selection."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
from llm_seam.providers.base import LLMProvider
|
|
6
|
+
from llm_seam.providers.mock import MockProvider
|
|
7
|
+
from llm_seam.providers.anthropic import AnthropicProvider
|
|
8
|
+
from llm_seam.providers.openai import OpenAIProvider
|
|
9
|
+
from llm_seam.providers.claude_cli import ClaudeCLIProvider
|
|
10
|
+
from llm_seam.providers.ollama import OllamaProvider
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from llm_seam.providers.agent_sdk import AgentSDKProvider
|
|
14
|
+
HAS_AGENT_SDK = True
|
|
15
|
+
except RuntimeError:
|
|
16
|
+
HAS_AGENT_SDK = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LLMClient:
|
|
20
|
+
"""Main LLM client with automatic provider selection.
|
|
21
|
+
|
|
22
|
+
Auto-selection priority:
|
|
23
|
+
1. Anthropic (if ANTHROPIC_API_KEY is set)
|
|
24
|
+
2. OpenAI (if OPENAI_API_KEY is set)
|
|
25
|
+
3. MockProvider (fallback when no API keys available)
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
provider: Provider instance or name string ('anthropic', 'openai', 'claude-cli', 'ollama', 'mock')
|
|
29
|
+
If None, auto-selects based on available API keys
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
# Auto-select based on environment
|
|
33
|
+
client = LLMClient()
|
|
34
|
+
|
|
35
|
+
# Explicit provider by name
|
|
36
|
+
client = LLMClient(provider="anthropic")
|
|
37
|
+
|
|
38
|
+
# Explicit provider instance
|
|
39
|
+
client = LLMClient(provider=AnthropicProvider(api_key="..."))
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, provider: LLMProvider | str | None = None):
|
|
43
|
+
if provider is None:
|
|
44
|
+
self.provider = self._auto_select_provider()
|
|
45
|
+
elif isinstance(provider, str):
|
|
46
|
+
self.provider = self._create_provider_from_name(provider)
|
|
47
|
+
else:
|
|
48
|
+
self.provider = provider
|
|
49
|
+
|
|
50
|
+
def _auto_select_provider(self) -> LLMProvider:
|
|
51
|
+
"""Auto-select provider based on available API keys."""
|
|
52
|
+
# Check Anthropic first
|
|
53
|
+
if os.getenv("ANTHROPIC_API_KEY"):
|
|
54
|
+
try:
|
|
55
|
+
return AnthropicProvider()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass # Fall through to next option
|
|
58
|
+
|
|
59
|
+
# Check OpenAI
|
|
60
|
+
if os.getenv("OPENAI_API_KEY"):
|
|
61
|
+
try:
|
|
62
|
+
return OpenAIProvider()
|
|
63
|
+
except Exception:
|
|
64
|
+
pass # Fall through to next option
|
|
65
|
+
|
|
66
|
+
# Default to mock provider
|
|
67
|
+
return MockProvider(response="Mock LLM response (no API keys configured)")
|
|
68
|
+
|
|
69
|
+
def _create_provider_from_name(self, name: str) -> LLMProvider:
|
|
70
|
+
"""Create provider instance from name string."""
|
|
71
|
+
providers: dict[str, type] = {
|
|
72
|
+
"anthropic": AnthropicProvider,
|
|
73
|
+
"openai": OpenAIProvider,
|
|
74
|
+
"claude-cli": ClaudeCLIProvider,
|
|
75
|
+
"ollama": OllamaProvider,
|
|
76
|
+
"mock": MockProvider,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if HAS_AGENT_SDK:
|
|
80
|
+
providers["claude-sdk"] = AgentSDKProvider
|
|
81
|
+
|
|
82
|
+
if name not in providers:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Unknown provider: {name}. "
|
|
85
|
+
f"Valid options: {', '.join(providers.keys())}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
provider_class = providers[name]
|
|
89
|
+
|
|
90
|
+
# MockProvider doesn't need any args
|
|
91
|
+
if name == "mock":
|
|
92
|
+
return provider_class()
|
|
93
|
+
|
|
94
|
+
# Other providers will use env vars or fail if not available
|
|
95
|
+
return provider_class()
|
|
96
|
+
|
|
97
|
+
def complete(self, prompt: str, **kwargs: Any) -> str:
|
|
98
|
+
"""Generate completion using the configured provider.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
prompt: The input prompt
|
|
102
|
+
**kwargs: Provider-specific options
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The completion text
|
|
106
|
+
"""
|
|
107
|
+
return self.provider.complete(prompt, **kwargs)
|
|
108
|
+
|
|
109
|
+
def get_provider_name(self) -> str:
|
|
110
|
+
"""Return the name of the current provider."""
|
|
111
|
+
return self.provider.get_name()
|
|
112
|
+
|
|
113
|
+
def supports_streaming(self) -> bool:
|
|
114
|
+
"""Return whether the current provider supports streaming."""
|
|
115
|
+
return self.provider.supports_streaming()
|
|
116
|
+
|
|
117
|
+
def set_provider(self, provider: LLMProvider | str) -> None:
|
|
118
|
+
"""Switch to a different provider.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
provider: Provider instance or name string
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(provider, str):
|
|
124
|
+
self.provider = self._create_provider_from_name(provider)
|
|
125
|
+
else:
|
|
126
|
+
self.provider = provider
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Custom exceptions for llm-seam."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LLMError(Exception):
|
|
5
|
+
"""Base exception for all LLM errors."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimitError(LLMError):
|
|
10
|
+
"""Raised when API rate limit is exceeded."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str = "Rate limit exceeded", retry_after: float | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.retry_after = retry_after
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenLimitError(LLMError):
|
|
18
|
+
"""Raised when token limit is exceeded."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TimeoutError(LLMError):
|
|
23
|
+
"""Raised when request times out."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AllProvidersFailed(LLMError):
|
|
28
|
+
"""Raised when all providers in a fallback chain fail."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, errors: list[Exception] | None = None):
|
|
31
|
+
self.errors = errors or []
|
|
32
|
+
message = f"All providers failed: {[str(e) for e in self.errors]}"
|
|
33
|
+
super().__init__(message)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Fallback chain for trying multiple LLM providers in order.
|
|
2
|
+
|
|
3
|
+
Based on pepai's llm_gateway.py local-first fallback pattern.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
from llm_seam.providers.base import LLMProvider
|
|
8
|
+
from llm_seam.exceptions import AllProvidersFailed
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FallbackChain:
|
|
12
|
+
"""Chain of LLM providers tried in order until one succeeds.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
providers: List of LLMProvider instances to try in order
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
# Try local Ollama first, fallback to Anthropic
|
|
19
|
+
chain = FallbackChain(providers=[
|
|
20
|
+
OllamaProvider(),
|
|
21
|
+
AnthropicProvider()
|
|
22
|
+
])
|
|
23
|
+
response = chain.complete("Hello")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, providers: list[LLMProvider]):
|
|
27
|
+
if not providers:
|
|
28
|
+
raise ValueError("At least one provider must be specified")
|
|
29
|
+
|
|
30
|
+
self.providers = providers
|
|
31
|
+
self.last_provider_used: LLMProvider | None = None
|
|
32
|
+
|
|
33
|
+
def complete(self, prompt: str, **kwargs: Any) -> str:
|
|
34
|
+
"""Try each provider in order until one succeeds.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
prompt: The input prompt
|
|
38
|
+
**kwargs: Additional parameters passed to each provider
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The completion text from the first successful provider
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
AllProvidersFailed: When all providers fail
|
|
45
|
+
"""
|
|
46
|
+
errors: list[Exception] = []
|
|
47
|
+
|
|
48
|
+
for provider in self.providers:
|
|
49
|
+
try:
|
|
50
|
+
response = provider.complete(prompt, **kwargs)
|
|
51
|
+
self.last_provider_used = provider
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
except Exception as e:
|
|
55
|
+
errors.append(e)
|
|
56
|
+
# Continue to next provider
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# All providers failed
|
|
60
|
+
self.last_provider_used = None
|
|
61
|
+
raise AllProvidersFailed(errors=errors)
|
|
62
|
+
|
|
63
|
+
def get_name(self) -> str:
|
|
64
|
+
"""Return fallback chain identifier."""
|
|
65
|
+
provider_names = [p.get_name() for p in self.providers]
|
|
66
|
+
return f"fallback({', '.join(provider_names)})"
|
|
67
|
+
|
|
68
|
+
def supports_streaming(self) -> bool:
|
|
69
|
+
"""Return whether streaming is supported (always False for chains)."""
|
|
70
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""LLM provider implementations."""
|
|
2
|
+
|
|
3
|
+
from .base import LLMProvider
|
|
4
|
+
from .mock import MockProvider
|
|
5
|
+
from .anthropic import AnthropicProvider
|
|
6
|
+
from .openai import OpenAIProvider
|
|
7
|
+
from .claude_cli import ClaudeCLIProvider
|
|
8
|
+
from .ollama import OllamaProvider
|
|
9
|
+
|
|
10
|
+
# Optional — requires pip install llm-seam[sdk]
|
|
11
|
+
try:
|
|
12
|
+
from .agent_sdk import AgentSDKProvider
|
|
13
|
+
except RuntimeError:
|
|
14
|
+
AgentSDKProvider = None # type: ignore[assignment,misc]
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"LLMProvider",
|
|
18
|
+
"MockProvider",
|
|
19
|
+
"AnthropicProvider",
|
|
20
|
+
"OpenAIProvider",
|
|
21
|
+
"ClaudeCLIProvider",
|
|
22
|
+
"OllamaProvider",
|
|
23
|
+
"AgentSDKProvider",
|
|
24
|
+
]
|