undertow-llm 0.2.1__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.
- undertow_llm-0.2.1/PKG-INFO +228 -0
- undertow_llm-0.2.1/README.md +180 -0
- undertow_llm-0.2.1/pyproject.toml +81 -0
- undertow_llm-0.2.1/setup.cfg +4 -0
- undertow_llm-0.2.1/tests/test_cache.py +156 -0
- undertow_llm-0.2.1/tests/test_canary.py +116 -0
- undertow_llm-0.2.1/tests/test_db.py +49 -0
- undertow_llm-0.2.1/tests/test_decorator.py +178 -0
- undertow_llm-0.2.1/tests/test_fallback.py +122 -0
- undertow_llm-0.2.1/tests/test_logging.py +133 -0
- undertow_llm-0.2.1/tests/test_postgres_integration.py +147 -0
- undertow_llm-0.2.1/tests/test_rate_limiter.py +191 -0
- undertow_llm-0.2.1/tests/test_redis_features.py +74 -0
- undertow_llm-0.2.1/tests/test_retry.py +168 -0
- undertow_llm-0.2.1/tests/test_storage_backends.py +95 -0
- undertow_llm-0.2.1/tests/test_streaming.py +129 -0
- undertow_llm-0.2.1/tests/test_tracing.py +89 -0
- undertow_llm-0.2.1/tests/test_view_postgres_metrics.py +250 -0
- undertow_llm-0.2.1/undertow_llm/__init__.py +26 -0
- undertow_llm-0.2.1/undertow_llm/backends/__init__.py +0 -0
- undertow_llm-0.2.1/undertow_llm/backends/base.py +93 -0
- undertow_llm-0.2.1/undertow_llm/backends/factory.py +136 -0
- undertow_llm-0.2.1/undertow_llm/backends/postgres_backend.py +424 -0
- undertow_llm-0.2.1/undertow_llm/backends/sqlite_backend.py +314 -0
- undertow_llm-0.2.1/undertow_llm/cache/__init__.py +1 -0
- undertow_llm-0.2.1/undertow_llm/cache/semantic.py +156 -0
- undertow_llm-0.2.1/undertow_llm/cache/store.py +11 -0
- undertow_llm-0.2.1/undertow_llm/canary.py +83 -0
- undertow_llm-0.2.1/undertow_llm/cli.py +61 -0
- undertow_llm-0.2.1/undertow_llm/config.py +105 -0
- undertow_llm-0.2.1/undertow_llm/db.py +98 -0
- undertow_llm-0.2.1/undertow_llm/decorator.py +452 -0
- undertow_llm-0.2.1/undertow_llm/fallback.py +116 -0
- undertow_llm-0.2.1/undertow_llm/logging/__init__.py +1 -0
- undertow_llm-0.2.1/undertow_llm/logging/store.py +32 -0
- undertow_llm-0.2.1/undertow_llm/queue.py +170 -0
- undertow_llm-0.2.1/undertow_llm/rate_limiter.py +247 -0
- undertow_llm-0.2.1/undertow_llm/retry.py +147 -0
- undertow_llm-0.2.1/undertow_llm/server/__init__.py +1 -0
- undertow_llm-0.2.1/undertow_llm/server/app.py +163 -0
- undertow_llm-0.2.1/undertow_llm/server/static/charts.js +517 -0
- undertow_llm-0.2.1/undertow_llm/server/static/favicon.png +0 -0
- undertow_llm-0.2.1/undertow_llm/server/static/style.css +572 -0
- undertow_llm-0.2.1/undertow_llm/server/templates/dashboard.html +377 -0
- undertow_llm-0.2.1/undertow_llm/streaming.py +90 -0
- undertow_llm-0.2.1/undertow_llm/tracing.py +59 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/PKG-INFO +228 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/SOURCES.txt +50 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/dependency_links.txt +1 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/entry_points.txt +2 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/requires.txt +36 -0
- undertow_llm-0.2.1/undertow_llm.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: undertow-llm
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Provider-agnostic LLM observability & reliability SDK — semantic caching, retries, rate limiting, cost tracking, and a local dashboard via a single @track() decorator.
|
|
5
|
+
Author: Amogh Arora
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ShriAmogh/undertow-llm
|
|
8
|
+
Project-URL: Repository, https://github.com/ShriAmogh/undertow-llm
|
|
9
|
+
Keywords: llm,observability,caching,openai,gemini,anthropic,sdk,ai-infra
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: sentence-transformers>=2.7.0
|
|
20
|
+
Requires-Dist: numpy>=1.26.0
|
|
21
|
+
Requires-Dist: fastapi>=0.111.0
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
23
|
+
Requires-Dist: jinja2>=3.1.4
|
|
24
|
+
Requires-Dist: click>=8.1.7
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
26
|
+
Requires-Dist: httpx>=0.27.0
|
|
27
|
+
Provides-Extra: gemini
|
|
28
|
+
Requires-Dist: google-genai>=1.0.0; extra == "gemini"
|
|
29
|
+
Provides-Extra: ollama
|
|
30
|
+
Requires-Dist: ollama>=0.2.0; extra == "ollama"
|
|
31
|
+
Provides-Extra: all-providers
|
|
32
|
+
Requires-Dist: google-genai>=1.0.0; extra == "all-providers"
|
|
33
|
+
Requires-Dist: ollama>=0.2.0; extra == "all-providers"
|
|
34
|
+
Provides-Extra: postgres
|
|
35
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == "postgres"
|
|
36
|
+
Requires-Dist: pgvector>=0.2.0; extra == "postgres"
|
|
37
|
+
Provides-Extra: redis
|
|
38
|
+
Requires-Dist: redis>=5.0.0; extra == "redis"
|
|
39
|
+
Provides-Extra: prod
|
|
40
|
+
Requires-Dist: psycopg2-binary>=2.9.0; extra == "prod"
|
|
41
|
+
Requires-Dist: pgvector>=0.2.0; extra == "prod"
|
|
42
|
+
Requires-Dist: redis>=5.0.0; extra == "prod"
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=8.2.0; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
47
|
+
Requires-Dist: locust>=2.28.0; extra == "dev"
|
|
48
|
+
|
|
49
|
+
# undertow_llm
|
|
50
|
+
|
|
51
|
+
**Wrap any LLM call, get caching, retries, rate limiting, and a real-time dashboard — with zero code changes to your model.**
|
|
52
|
+
|
|
53
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
54
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
55
|
+
[](LICENSE)
|
|
56
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Problem Statement
|
|
61
|
+
|
|
62
|
+
Without an observability and resilience layer, production LLM applications suffer from soaring API costs due to redundant prompt calls, unexpected provider outages with zero fallback protection, and complete lack of visibility into latency and errors. `undertow_llm` solves this by wrapping your existing Python LLM functions in a single decorator—providing semantic caching, automated retries, rate limiting, and a live dashboard without modifying your model logic.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install undertow-llm
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or install with provider & production backend extras:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install "undertow-llm[gemini]" # Google Gemini support
|
|
76
|
+
pip install "undertow-llm[ollama]" # Ollama local model support
|
|
77
|
+
pip install "undertow-llm[postgres]" # PostgreSQL + pgvector backend
|
|
78
|
+
pip install "undertow-llm[redis]" # Redis rate-limiting backend
|
|
79
|
+
pip install "undertow-llm[prod]" # Production stack (Postgres + Redis)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Quickstart
|
|
85
|
+
|
|
86
|
+
**Before** (bare LLM call — no caching, no fallback, no observability):
|
|
87
|
+
```python
|
|
88
|
+
def generate_response(prompt: str) -> str:
|
|
89
|
+
return client.models.generate_content("gemini-2.5-flash", prompt).text
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**After** (wrapped with `@track()` — fully resilient & tracked):
|
|
93
|
+
```python
|
|
94
|
+
from undertow_llm import track
|
|
95
|
+
|
|
96
|
+
@track(cache=True, retries=3, rate_limit_rate=2.0)
|
|
97
|
+
def generate_response(prompt: str) -> str:
|
|
98
|
+
return client.models.generate_content("gemini-2.5-flash", prompt).text
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Copy-paste into your application and run. Zero edits required except setting your provider API key.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Real-Time Dashboard
|
|
106
|
+
|
|
107
|
+
Start the live observability dashboard in one command:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
undertow-llm serve
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Open `http://localhost:8080` to inspect real-time metrics, cache hit ratios, latency charts, cost estimates, distributed traces, and request logs.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## How It Works
|
|
118
|
+
|
|
119
|
+
1. The `@track()` decorator wraps your function, intercepting incoming prompts before execution.
|
|
120
|
+
2. It performs a vector similarity search (using `SentenceTransformers`) to serve semantic cache hits instantly and applies token-bucket rate limits.
|
|
121
|
+
3. Upon function completion, it records latency, token usage, estimated cost, and execution traces to storage.
|
|
122
|
+
4. It is provider-agnostic because it wraps your Python function call directly and never touches your underlying model SDK.
|
|
123
|
+
|
|
124
|
+
For a detailed architectural breakdown of the 8-stage execution pipeline and backend dispatcher, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
**Local dev needs zero config** — defaults out-of-the-box to local SQLite (`undertow_llm.db`).
|
|
131
|
+
|
|
132
|
+
For production environments, configure via environment variables or `configure()`:
|
|
133
|
+
|
|
134
|
+
| Environment Variable | Default | Description |
|
|
135
|
+
|----------------------|---------|-------------|
|
|
136
|
+
| `UNDERTOW_LLM_POSTGRES_URL` | `None` (SQLite) | PostgreSQL URL with `pgvector` for production vector storage & metrics |
|
|
137
|
+
| `UNDERTOW_LLM_REDIS_URL` | `None` (Local) | Redis URL for distributed rate limiting & token buckets |
|
|
138
|
+
| `UNDERTOW_LLM_DB_PATH` | `"undertow-llm.db"` | File path for local SQLite database fallback |
|
|
139
|
+
| `UNDERTOW_LLM_DASHBOARD_PORT` | `8080` | HTTP port for `undertow-llm serve` dashboard |
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## `@track()` Parameter Reference
|
|
144
|
+
|
|
145
|
+
| Parameter | Type | Default | Description |
|
|
146
|
+
|-----------|------|---------|-------------|
|
|
147
|
+
| `cache` | `bool` | `True` | Enable semantic caching for responses |
|
|
148
|
+
| `similarity_threshold` | `float` | `0.92` | Cosine similarity threshold for cache hits (0.0 to 1.0) |
|
|
149
|
+
| `cache_ttl` | `int \| None` | `None` | Optional time-to-live in seconds for cached entries |
|
|
150
|
+
| `retries` | `int` | `3` | Max retry attempts for transient LLM failures |
|
|
151
|
+
| `base_delay` | `float` | `1.0` | Initial exponential backoff delay (seconds) |
|
|
152
|
+
| `max_delay` | `float` | `60.0` | Cap on exponential backoff delay (seconds) |
|
|
153
|
+
| `jitter` | `bool` | `True` | Add randomized jitter to retry delays to prevent thundering herds |
|
|
154
|
+
| `retry_on` | `tuple` | `(Exception,)` | Exception types that trigger automatic retries |
|
|
155
|
+
| `fallback` | `list` | `[]` | Ordered list of fallback functions to call if primary function fails |
|
|
156
|
+
| `rate_limit_rate` | `float` | `2.0` | Token-bucket refill rate (tokens/second) |
|
|
157
|
+
| `rate_limit_max_tokens` | `float` | `10.0` | Token-bucket maximum capacity (burst limit) |
|
|
158
|
+
| `max_concurrency` | `int \| None` | `None` | Max concurrent executions allowed across processes |
|
|
159
|
+
| `cost_per_call` | `float \| None` | `None` | Explicit cost override per call ($/call) |
|
|
160
|
+
| `policy` | `callable \| None` | `None` | Custom safety hook returning `"allow"`, `"block"`, or `"flag"` |
|
|
161
|
+
| `canary` | `dict \| None` | `None` | Canary routing config `{"fn": alternate_fn, "weight": 0.10}` |
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Supported Providers
|
|
166
|
+
|
|
167
|
+
`undertow_llm` works with **any provider** — OpenAI, Anthropic, Google Gemini, Ollama, HuggingFace, or custom local models — since it wraps your existing Python function call rather than a specific provider SDK.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Examples
|
|
172
|
+
|
|
173
|
+
See [`demo/example_usage.py`](demo/example_usage.py) for complete runnable examples.
|
|
174
|
+
|
|
175
|
+
### 1. Multi-Provider Fallback Chain
|
|
176
|
+
```python
|
|
177
|
+
from undertow_llm import track
|
|
178
|
+
|
|
179
|
+
def fallback_anthropic(prompt: str) -> str:
|
|
180
|
+
return anthropic_client.messages.create(model="claude-3-5-sonnet", messages=[{"role": "user", "content": prompt}]).content[0].text
|
|
181
|
+
|
|
182
|
+
@track(retries=2, fallback=[fallback_anthropic])
|
|
183
|
+
def primary_openai(prompt: str) -> str:
|
|
184
|
+
return openai_client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": prompt}]).choices[0].message.content
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 2. Streaming LLM Response
|
|
188
|
+
```python
|
|
189
|
+
@track(cache=False)
|
|
190
|
+
def stream_gemini(prompt: str):
|
|
191
|
+
response = gemini_client.models.generate_content_stream("gemini-2.5-flash", prompt)
|
|
192
|
+
for chunk in response:
|
|
193
|
+
yield chunk.text
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 3. Local Model (Ollama) with Custom Usage Extractor
|
|
197
|
+
```python
|
|
198
|
+
@track(
|
|
199
|
+
cost_per_call=0.0, # Local model — zero API cost
|
|
200
|
+
usage_extractor=lambda res: {"prompt_tokens": len(res.get("prompt", "")), "completion_tokens": len(res.get("response", ""))}
|
|
201
|
+
)
|
|
202
|
+
def ask_ollama(prompt: str) -> dict:
|
|
203
|
+
return ollama.generate(model="llama3", prompt=prompt)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Known Limitations & Roadmap
|
|
209
|
+
|
|
210
|
+
### Limitations
|
|
211
|
+
- **SQLite Concurrency**: Local SQLite storage (`undertow_llm.db`) is zero-config and ideal for development and single-instance apps, but is not designed for multi-node production scale. For high concurrency, set `UNDERTOW_LLM_POSTGRES_URL` and `UNDERTOW_LLM_REDIS_URL`.
|
|
212
|
+
|
|
213
|
+
### Near-Term Roadmap
|
|
214
|
+
- [ ] OpenTelemetry trace exporter integration
|
|
215
|
+
- [ ] Multi-tenant workspace tagging & dashboard authentication
|
|
216
|
+
- [ ] Automated PII redaction and sensitive prompt masking filters
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Contributing
|
|
221
|
+
|
|
222
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for developer setup instructions.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# undertow_llm
|
|
2
|
+
|
|
3
|
+
**Wrap any LLM call, get caching, retries, rate limiting, and a real-time dashboard — with zero code changes to your model.**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
6
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://pypi.org/project/undertow_llm/)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Problem Statement
|
|
13
|
+
|
|
14
|
+
Without an observability and resilience layer, production LLM applications suffer from soaring API costs due to redundant prompt calls, unexpected provider outages with zero fallback protection, and complete lack of visibility into latency and errors. `undertow_llm` solves this by wrapping your existing Python LLM functions in a single decorator—providing semantic caching, automated retries, rate limiting, and a live dashboard without modifying your model logic.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install undertow-llm
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or install with provider & production backend extras:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install "undertow-llm[gemini]" # Google Gemini support
|
|
28
|
+
pip install "undertow-llm[ollama]" # Ollama local model support
|
|
29
|
+
pip install "undertow-llm[postgres]" # PostgreSQL + pgvector backend
|
|
30
|
+
pip install "undertow-llm[redis]" # Redis rate-limiting backend
|
|
31
|
+
pip install "undertow-llm[prod]" # Production stack (Postgres + Redis)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Quickstart
|
|
37
|
+
|
|
38
|
+
**Before** (bare LLM call — no caching, no fallback, no observability):
|
|
39
|
+
```python
|
|
40
|
+
def generate_response(prompt: str) -> str:
|
|
41
|
+
return client.models.generate_content("gemini-2.5-flash", prompt).text
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**After** (wrapped with `@track()` — fully resilient & tracked):
|
|
45
|
+
```python
|
|
46
|
+
from undertow_llm import track
|
|
47
|
+
|
|
48
|
+
@track(cache=True, retries=3, rate_limit_rate=2.0)
|
|
49
|
+
def generate_response(prompt: str) -> str:
|
|
50
|
+
return client.models.generate_content("gemini-2.5-flash", prompt).text
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Copy-paste into your application and run. Zero edits required except setting your provider API key.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Real-Time Dashboard
|
|
58
|
+
|
|
59
|
+
Start the live observability dashboard in one command:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
undertow-llm serve
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Open `http://localhost:8080` to inspect real-time metrics, cache hit ratios, latency charts, cost estimates, distributed traces, and request logs.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## How It Works
|
|
70
|
+
|
|
71
|
+
1. The `@track()` decorator wraps your function, intercepting incoming prompts before execution.
|
|
72
|
+
2. It performs a vector similarity search (using `SentenceTransformers`) to serve semantic cache hits instantly and applies token-bucket rate limits.
|
|
73
|
+
3. Upon function completion, it records latency, token usage, estimated cost, and execution traces to storage.
|
|
74
|
+
4. It is provider-agnostic because it wraps your Python function call directly and never touches your underlying model SDK.
|
|
75
|
+
|
|
76
|
+
For a detailed architectural breakdown of the 8-stage execution pipeline and backend dispatcher, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
**Local dev needs zero config** — defaults out-of-the-box to local SQLite (`undertow_llm.db`).
|
|
83
|
+
|
|
84
|
+
For production environments, configure via environment variables or `configure()`:
|
|
85
|
+
|
|
86
|
+
| Environment Variable | Default | Description |
|
|
87
|
+
|----------------------|---------|-------------|
|
|
88
|
+
| `UNDERTOW_LLM_POSTGRES_URL` | `None` (SQLite) | PostgreSQL URL with `pgvector` for production vector storage & metrics |
|
|
89
|
+
| `UNDERTOW_LLM_REDIS_URL` | `None` (Local) | Redis URL for distributed rate limiting & token buckets |
|
|
90
|
+
| `UNDERTOW_LLM_DB_PATH` | `"undertow-llm.db"` | File path for local SQLite database fallback |
|
|
91
|
+
| `UNDERTOW_LLM_DASHBOARD_PORT` | `8080` | HTTP port for `undertow-llm serve` dashboard |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## `@track()` Parameter Reference
|
|
96
|
+
|
|
97
|
+
| Parameter | Type | Default | Description |
|
|
98
|
+
|-----------|------|---------|-------------|
|
|
99
|
+
| `cache` | `bool` | `True` | Enable semantic caching for responses |
|
|
100
|
+
| `similarity_threshold` | `float` | `0.92` | Cosine similarity threshold for cache hits (0.0 to 1.0) |
|
|
101
|
+
| `cache_ttl` | `int \| None` | `None` | Optional time-to-live in seconds for cached entries |
|
|
102
|
+
| `retries` | `int` | `3` | Max retry attempts for transient LLM failures |
|
|
103
|
+
| `base_delay` | `float` | `1.0` | Initial exponential backoff delay (seconds) |
|
|
104
|
+
| `max_delay` | `float` | `60.0` | Cap on exponential backoff delay (seconds) |
|
|
105
|
+
| `jitter` | `bool` | `True` | Add randomized jitter to retry delays to prevent thundering herds |
|
|
106
|
+
| `retry_on` | `tuple` | `(Exception,)` | Exception types that trigger automatic retries |
|
|
107
|
+
| `fallback` | `list` | `[]` | Ordered list of fallback functions to call if primary function fails |
|
|
108
|
+
| `rate_limit_rate` | `float` | `2.0` | Token-bucket refill rate (tokens/second) |
|
|
109
|
+
| `rate_limit_max_tokens` | `float` | `10.0` | Token-bucket maximum capacity (burst limit) |
|
|
110
|
+
| `max_concurrency` | `int \| None` | `None` | Max concurrent executions allowed across processes |
|
|
111
|
+
| `cost_per_call` | `float \| None` | `None` | Explicit cost override per call ($/call) |
|
|
112
|
+
| `policy` | `callable \| None` | `None` | Custom safety hook returning `"allow"`, `"block"`, or `"flag"` |
|
|
113
|
+
| `canary` | `dict \| None` | `None` | Canary routing config `{"fn": alternate_fn, "weight": 0.10}` |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Supported Providers
|
|
118
|
+
|
|
119
|
+
`undertow_llm` works with **any provider** — OpenAI, Anthropic, Google Gemini, Ollama, HuggingFace, or custom local models — since it wraps your existing Python function call rather than a specific provider SDK.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Examples
|
|
124
|
+
|
|
125
|
+
See [`demo/example_usage.py`](demo/example_usage.py) for complete runnable examples.
|
|
126
|
+
|
|
127
|
+
### 1. Multi-Provider Fallback Chain
|
|
128
|
+
```python
|
|
129
|
+
from undertow_llm import track
|
|
130
|
+
|
|
131
|
+
def fallback_anthropic(prompt: str) -> str:
|
|
132
|
+
return anthropic_client.messages.create(model="claude-3-5-sonnet", messages=[{"role": "user", "content": prompt}]).content[0].text
|
|
133
|
+
|
|
134
|
+
@track(retries=2, fallback=[fallback_anthropic])
|
|
135
|
+
def primary_openai(prompt: str) -> str:
|
|
136
|
+
return openai_client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": prompt}]).choices[0].message.content
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 2. Streaming LLM Response
|
|
140
|
+
```python
|
|
141
|
+
@track(cache=False)
|
|
142
|
+
def stream_gemini(prompt: str):
|
|
143
|
+
response = gemini_client.models.generate_content_stream("gemini-2.5-flash", prompt)
|
|
144
|
+
for chunk in response:
|
|
145
|
+
yield chunk.text
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 3. Local Model (Ollama) with Custom Usage Extractor
|
|
149
|
+
```python
|
|
150
|
+
@track(
|
|
151
|
+
cost_per_call=0.0, # Local model — zero API cost
|
|
152
|
+
usage_extractor=lambda res: {"prompt_tokens": len(res.get("prompt", "")), "completion_tokens": len(res.get("response", ""))}
|
|
153
|
+
)
|
|
154
|
+
def ask_ollama(prompt: str) -> dict:
|
|
155
|
+
return ollama.generate(model="llama3", prompt=prompt)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Known Limitations & Roadmap
|
|
161
|
+
|
|
162
|
+
### Limitations
|
|
163
|
+
- **SQLite Concurrency**: Local SQLite storage (`undertow_llm.db`) is zero-config and ideal for development and single-instance apps, but is not designed for multi-node production scale. For high concurrency, set `UNDERTOW_LLM_POSTGRES_URL` and `UNDERTOW_LLM_REDIS_URL`.
|
|
164
|
+
|
|
165
|
+
### Near-Term Roadmap
|
|
166
|
+
- [ ] OpenTelemetry trace exporter integration
|
|
167
|
+
- [ ] Multi-tenant workspace tagging & dashboard authentication
|
|
168
|
+
- [ ] Automated PII redaction and sensitive prompt masking filters
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Contributing
|
|
173
|
+
|
|
174
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for developer setup instructions.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "undertow-llm"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Provider-agnostic LLM observability & reliability SDK — semantic caching, retries, rate limiting, cost tracking, and a local dashboard via a single @track() decorator."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["llm", "observability", "caching", "openai", "gemini", "anthropic", "sdk", "ai-infra"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
authors = [{ name = "Amogh Arora" }]
|
|
23
|
+
dependencies = [
|
|
24
|
+
# Semantic cache
|
|
25
|
+
"sentence-transformers>=2.7.0",
|
|
26
|
+
"numpy>=1.26.0",
|
|
27
|
+
|
|
28
|
+
# Dashboard backend
|
|
29
|
+
"fastapi>=0.111.0",
|
|
30
|
+
"uvicorn[standard]>=0.29.0",
|
|
31
|
+
"jinja2>=3.1.4",
|
|
32
|
+
|
|
33
|
+
# CLI
|
|
34
|
+
"click>=8.1.7",
|
|
35
|
+
|
|
36
|
+
# Utilities
|
|
37
|
+
"python-dotenv>=1.0.1",
|
|
38
|
+
"httpx>=0.27.0", # async HTTP for alerting webhook
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# LLM providers are optional — undertow_llm is provider-agnostic.
|
|
42
|
+
# Install the provider you use, e.g.: pip install undertow-llm[gemini]
|
|
43
|
+
[project.optional-dependencies]
|
|
44
|
+
gemini = ["google-genai>=1.0.0"]
|
|
45
|
+
ollama = ["ollama>=0.2.0"]
|
|
46
|
+
all-providers = ["google-genai>=1.0.0", "ollama>=0.2.0"]
|
|
47
|
+
postgres = ["psycopg2-binary>=2.9.0", "pgvector>=0.2.0"]
|
|
48
|
+
redis = ["redis>=5.0.0"]
|
|
49
|
+
prod = ["psycopg2-binary>=2.9.0", "pgvector>=0.2.0", "redis>=5.0.0"]
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=8.2.0",
|
|
52
|
+
"pytest-asyncio>=0.23.0",
|
|
53
|
+
"pytest-cov>=5.0.0",
|
|
54
|
+
"locust>=2.28.0",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[project.scripts]
|
|
58
|
+
undertow-llm = "undertow_llm.cli:main"
|
|
59
|
+
|
|
60
|
+
[project.urls]
|
|
61
|
+
Homepage = "https://github.com/ShriAmogh/undertow-llm"
|
|
62
|
+
Repository = "https://github.com/ShriAmogh/undertow-llm"
|
|
63
|
+
|
|
64
|
+
[tool.setuptools.packages.find]
|
|
65
|
+
where = ["."]
|
|
66
|
+
include = ["undertow_llm*"]
|
|
67
|
+
|
|
68
|
+
# Bundle the dashboard HTML/CSS/JS — without this the dashboard silently breaks
|
|
69
|
+
# when the package is installed via pip (non-Python files are ignored by default).
|
|
70
|
+
[tool.setuptools.package-data]
|
|
71
|
+
"undertow_llm" = [
|
|
72
|
+
"server/templates/*.html",
|
|
73
|
+
"server/static/*.css",
|
|
74
|
+
"server/static/*.js",
|
|
75
|
+
"server/static/*.png",
|
|
76
|
+
"server/static/*.svg",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.pytest.ini_options]
|
|
80
|
+
asyncio_mode = "auto"
|
|
81
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tests/test_cache.py
|
|
3
|
+
===================
|
|
4
|
+
Phase 2 tests: semantic cache — embedding, cosine similarity, store.
|
|
5
|
+
|
|
6
|
+
We mock the sentence-transformer model so tests run fast (no 80MB model
|
|
7
|
+
download in CI), but keep the cosine similarity math unpatched so we
|
|
8
|
+
validate the algorithm itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
from unittest.mock import patch, MagicMock
|
|
17
|
+
|
|
18
|
+
from undertow_llm.cache.semantic import cosine_similarity, embedding_to_bytes, bytes_to_embedding
|
|
19
|
+
from undertow_llm.cache.store import CacheStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def cache_store(temp_db):
|
|
26
|
+
return CacheStore(db_path=temp_db)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fake_embed(text: str) -> np.ndarray:
|
|
30
|
+
"""
|
|
31
|
+
Deterministic fake embedding for tests.
|
|
32
|
+
Near-duplicates (same first 20 chars) get a nearly identical vector.
|
|
33
|
+
Dissimilar texts get a near-orthogonal vector.
|
|
34
|
+
"""
|
|
35
|
+
np.random.seed(hash(text[:20]) % (2**31))
|
|
36
|
+
base = np.random.rand(384).astype(np.float32)
|
|
37
|
+
# Add a small perturbation for texts with same prefix (simulates near-duplicates)
|
|
38
|
+
if len(text) > 20:
|
|
39
|
+
np.random.seed(hash(text) % (2**31))
|
|
40
|
+
perturbation = np.random.rand(384).astype(np.float32) * 0.05
|
|
41
|
+
base = base + perturbation
|
|
42
|
+
# Normalise
|
|
43
|
+
return base / np.linalg.norm(base)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Cosine Similarity ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
class TestCosineSimilarity:
|
|
49
|
+
def test_identical_vectors_score_one(self):
|
|
50
|
+
v = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
|
51
|
+
assert cosine_similarity(v, v) == pytest.approx(1.0, abs=1e-6)
|
|
52
|
+
|
|
53
|
+
def test_orthogonal_vectors_score_zero(self):
|
|
54
|
+
a = np.array([1.0, 0.0, 0.0], dtype=np.float32)
|
|
55
|
+
b = np.array([0.0, 1.0, 0.0], dtype=np.float32)
|
|
56
|
+
assert cosine_similarity(a, b) == pytest.approx(0.0, abs=1e-6)
|
|
57
|
+
|
|
58
|
+
def test_opposite_vectors_score_minus_one(self):
|
|
59
|
+
a = np.array([1.0, 0.0], dtype=np.float32)
|
|
60
|
+
b = np.array([-1.0, 0.0], dtype=np.float32)
|
|
61
|
+
assert cosine_similarity(a, b) == pytest.approx(-1.0, abs=1e-6)
|
|
62
|
+
|
|
63
|
+
def test_similar_vectors_score_high(self):
|
|
64
|
+
# Two nearly-identical unit vectors
|
|
65
|
+
a = np.array([0.6, 0.8, 0.0], dtype=np.float32)
|
|
66
|
+
b = np.array([0.601, 0.799, 0.01], dtype=np.float32)
|
|
67
|
+
b = b / np.linalg.norm(b)
|
|
68
|
+
score = cosine_similarity(a, b)
|
|
69
|
+
assert score > 0.99
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Serialisation ─────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
class TestSerialization:
|
|
75
|
+
def test_roundtrip_preserves_vector(self):
|
|
76
|
+
original = np.random.rand(384).astype(np.float32)
|
|
77
|
+
blob = embedding_to_bytes(original)
|
|
78
|
+
recovered = bytes_to_embedding(blob)
|
|
79
|
+
np.testing.assert_array_almost_equal(original, recovered)
|
|
80
|
+
|
|
81
|
+
def test_blob_is_bytes(self):
|
|
82
|
+
v = np.ones(384, dtype=np.float32)
|
|
83
|
+
assert isinstance(embedding_to_bytes(v), bytes)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── CacheStore ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
class TestCacheStore:
|
|
89
|
+
def test_miss_on_empty_store(self, cache_store):
|
|
90
|
+
emb = _fake_embed("What is Python?")
|
|
91
|
+
result = cache_store.lookup(emb, threshold=0.92)
|
|
92
|
+
assert result is None
|
|
93
|
+
|
|
94
|
+
def test_write_then_exact_hit(self, cache_store):
|
|
95
|
+
"""Exact same prompt embedding should always hit."""
|
|
96
|
+
prompt = "What is Python?"
|
|
97
|
+
emb = _fake_embed(prompt)
|
|
98
|
+
|
|
99
|
+
cache_store.write(prompt, emb, "Python is a programming language.", ttl=None)
|
|
100
|
+
result = cache_store.lookup(emb, threshold=0.92)
|
|
101
|
+
|
|
102
|
+
assert result is not None
|
|
103
|
+
assert result.response == "Python is a programming language."
|
|
104
|
+
assert result.similarity == pytest.approx(1.0, abs=1e-4)
|
|
105
|
+
|
|
106
|
+
def test_dissimilar_prompt_misses(self, cache_store):
|
|
107
|
+
"""A semantically unrelated prompt must not hit the cache."""
|
|
108
|
+
prompt_a = "What is Python?"
|
|
109
|
+
emb_a = _fake_embed(prompt_a)
|
|
110
|
+
cache_store.write(prompt_a, emb_a, "Python is a language.", ttl=None)
|
|
111
|
+
|
|
112
|
+
# Chocolate cake is completely unrelated → orthogonal embedding
|
|
113
|
+
emb_b = _fake_embed("What is the best chocolate cake recipe?") * -1 # force dissimilar
|
|
114
|
+
emb_b = emb_b / np.linalg.norm(emb_b)
|
|
115
|
+
|
|
116
|
+
result = cache_store.lookup(emb_b, threshold=0.92)
|
|
117
|
+
assert result is None
|
|
118
|
+
|
|
119
|
+
def test_ttl_expiry(self, cache_store):
|
|
120
|
+
"""An entry with expired TTL must not be returned."""
|
|
121
|
+
prompt = "Test TTL"
|
|
122
|
+
emb = _fake_embed(prompt)
|
|
123
|
+
# Write with TTL of 1 second
|
|
124
|
+
cache_store.write(prompt, emb, "cached response", ttl=1)
|
|
125
|
+
|
|
126
|
+
# Should hit immediately
|
|
127
|
+
assert cache_store.lookup(emb, threshold=0.90) is not None
|
|
128
|
+
|
|
129
|
+
# Wait for expiry
|
|
130
|
+
time.sleep(1.1)
|
|
131
|
+
assert cache_store.lookup(emb, threshold=0.90) is None
|
|
132
|
+
|
|
133
|
+
def test_hit_count_increments(self, cache_store):
|
|
134
|
+
"""hit_count must increment on each cache hit."""
|
|
135
|
+
emb = _fake_embed("Python question")
|
|
136
|
+
cache_store.write("Python question", emb, "answer", ttl=None)
|
|
137
|
+
|
|
138
|
+
result1 = cache_store.lookup(emb, threshold=0.90)
|
|
139
|
+
result2 = cache_store.lookup(emb, threshold=0.90)
|
|
140
|
+
|
|
141
|
+
assert result1.hit_count == 1
|
|
142
|
+
assert result2.hit_count == 2
|
|
143
|
+
|
|
144
|
+
def test_count_returns_valid_entries(self, cache_store):
|
|
145
|
+
"""count() should return the number of non-expired entries."""
|
|
146
|
+
assert cache_store.count() == 0
|
|
147
|
+
cache_store.write("q1", _fake_embed("q1"), "a1", ttl=None)
|
|
148
|
+
cache_store.write("q2", _fake_embed("q2"), "a2", ttl=None)
|
|
149
|
+
assert cache_store.count() == 2
|
|
150
|
+
|
|
151
|
+
def test_expired_entries_excluded_from_count(self, cache_store):
|
|
152
|
+
"""count() must not include expired entries."""
|
|
153
|
+
cache_store.write("q", _fake_embed("q"), "a", ttl=1)
|
|
154
|
+
assert cache_store.count() == 1
|
|
155
|
+
time.sleep(1.1)
|
|
156
|
+
assert cache_store.count() == 0
|