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.
Files changed (52) hide show
  1. undertow_llm-0.2.1/PKG-INFO +228 -0
  2. undertow_llm-0.2.1/README.md +180 -0
  3. undertow_llm-0.2.1/pyproject.toml +81 -0
  4. undertow_llm-0.2.1/setup.cfg +4 -0
  5. undertow_llm-0.2.1/tests/test_cache.py +156 -0
  6. undertow_llm-0.2.1/tests/test_canary.py +116 -0
  7. undertow_llm-0.2.1/tests/test_db.py +49 -0
  8. undertow_llm-0.2.1/tests/test_decorator.py +178 -0
  9. undertow_llm-0.2.1/tests/test_fallback.py +122 -0
  10. undertow_llm-0.2.1/tests/test_logging.py +133 -0
  11. undertow_llm-0.2.1/tests/test_postgres_integration.py +147 -0
  12. undertow_llm-0.2.1/tests/test_rate_limiter.py +191 -0
  13. undertow_llm-0.2.1/tests/test_redis_features.py +74 -0
  14. undertow_llm-0.2.1/tests/test_retry.py +168 -0
  15. undertow_llm-0.2.1/tests/test_storage_backends.py +95 -0
  16. undertow_llm-0.2.1/tests/test_streaming.py +129 -0
  17. undertow_llm-0.2.1/tests/test_tracing.py +89 -0
  18. undertow_llm-0.2.1/tests/test_view_postgres_metrics.py +250 -0
  19. undertow_llm-0.2.1/undertow_llm/__init__.py +26 -0
  20. undertow_llm-0.2.1/undertow_llm/backends/__init__.py +0 -0
  21. undertow_llm-0.2.1/undertow_llm/backends/base.py +93 -0
  22. undertow_llm-0.2.1/undertow_llm/backends/factory.py +136 -0
  23. undertow_llm-0.2.1/undertow_llm/backends/postgres_backend.py +424 -0
  24. undertow_llm-0.2.1/undertow_llm/backends/sqlite_backend.py +314 -0
  25. undertow_llm-0.2.1/undertow_llm/cache/__init__.py +1 -0
  26. undertow_llm-0.2.1/undertow_llm/cache/semantic.py +156 -0
  27. undertow_llm-0.2.1/undertow_llm/cache/store.py +11 -0
  28. undertow_llm-0.2.1/undertow_llm/canary.py +83 -0
  29. undertow_llm-0.2.1/undertow_llm/cli.py +61 -0
  30. undertow_llm-0.2.1/undertow_llm/config.py +105 -0
  31. undertow_llm-0.2.1/undertow_llm/db.py +98 -0
  32. undertow_llm-0.2.1/undertow_llm/decorator.py +452 -0
  33. undertow_llm-0.2.1/undertow_llm/fallback.py +116 -0
  34. undertow_llm-0.2.1/undertow_llm/logging/__init__.py +1 -0
  35. undertow_llm-0.2.1/undertow_llm/logging/store.py +32 -0
  36. undertow_llm-0.2.1/undertow_llm/queue.py +170 -0
  37. undertow_llm-0.2.1/undertow_llm/rate_limiter.py +247 -0
  38. undertow_llm-0.2.1/undertow_llm/retry.py +147 -0
  39. undertow_llm-0.2.1/undertow_llm/server/__init__.py +1 -0
  40. undertow_llm-0.2.1/undertow_llm/server/app.py +163 -0
  41. undertow_llm-0.2.1/undertow_llm/server/static/charts.js +517 -0
  42. undertow_llm-0.2.1/undertow_llm/server/static/favicon.png +0 -0
  43. undertow_llm-0.2.1/undertow_llm/server/static/style.css +572 -0
  44. undertow_llm-0.2.1/undertow_llm/server/templates/dashboard.html +377 -0
  45. undertow_llm-0.2.1/undertow_llm/streaming.py +90 -0
  46. undertow_llm-0.2.1/undertow_llm/tracing.py +59 -0
  47. undertow_llm-0.2.1/undertow_llm.egg-info/PKG-INFO +228 -0
  48. undertow_llm-0.2.1/undertow_llm.egg-info/SOURCES.txt +50 -0
  49. undertow_llm-0.2.1/undertow_llm.egg-info/dependency_links.txt +1 -0
  50. undertow_llm-0.2.1/undertow_llm.egg-info/entry_points.txt +2 -0
  51. undertow_llm-0.2.1/undertow_llm.egg-info/requires.txt +36 -0
  52. 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
+ [![PyPI version](https://img.shields.io/pypi/v/undertow_llm.svg)](https://pypi.org/project/undertow_llm/)
54
+ [![Python versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://pypi.org/project/undertow_llm/)
55
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
56
+ [![Status](https://img.shields.io/badge/status-active-brightgreen.svg)](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
+ [![PyPI version](https://img.shields.io/pypi/v/undertow_llm.svg)](https://pypi.org/project/undertow_llm/)
6
+ [![Python versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue.svg)](https://pypi.org/project/undertow_llm/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+ [![Status](https://img.shields.io/badge/status-active-brightgreen.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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