cachellm-proxy 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.
Files changed (43) hide show
  1. cachellm_proxy-0.1.0/PKG-INFO +550 -0
  2. cachellm_proxy-0.1.0/README.md +502 -0
  3. cachellm_proxy-0.1.0/pyproject.toml +173 -0
  4. cachellm_proxy-0.1.0/pyproject.toml.orig +156 -0
  5. cachellm_proxy-0.1.0/src/cachellm/__init__.py +13 -0
  6. cachellm_proxy-0.1.0/src/cachellm/__main__.py +6 -0
  7. cachellm_proxy-0.1.0/src/cachellm/api/__init__.py +5 -0
  8. cachellm_proxy-0.1.0/src/cachellm/api/app.py +143 -0
  9. cachellm_proxy-0.1.0/src/cachellm/api/auth.py +34 -0
  10. cachellm_proxy-0.1.0/src/cachellm/api/deps.py +106 -0
  11. cachellm_proxy-0.1.0/src/cachellm/api/routes_admin.py +265 -0
  12. cachellm_proxy-0.1.0/src/cachellm/api/routes_chat.py +385 -0
  13. cachellm_proxy-0.1.0/src/cachellm/api/sse.py +98 -0
  14. cachellm_proxy-0.1.0/src/cachellm/cache/__init__.py +3 -0
  15. cachellm_proxy-0.1.0/src/cachellm/cache/analytics.py +150 -0
  16. cachellm_proxy-0.1.0/src/cachellm/cache/coalesce.py +63 -0
  17. cachellm_proxy-0.1.0/src/cachellm/cache/entry.py +92 -0
  18. cachellm_proxy-0.1.0/src/cachellm/cache/exact_store.py +33 -0
  19. cachellm_proxy-0.1.0/src/cachellm/cache/keys.py +124 -0
  20. cachellm_proxy-0.1.0/src/cachellm/cache/policy.py +134 -0
  21. cachellm_proxy-0.1.0/src/cachellm/cache/redis_client.py +22 -0
  22. cachellm_proxy-0.1.0/src/cachellm/cache/service.py +332 -0
  23. cachellm_proxy-0.1.0/src/cachellm/cache/vector_store.py +217 -0
  24. cachellm_proxy-0.1.0/src/cachellm/cli.py +122 -0
  25. cachellm_proxy-0.1.0/src/cachellm/embeddings/__init__.py +19 -0
  26. cachellm_proxy-0.1.0/src/cachellm/embeddings/base.py +38 -0
  27. cachellm_proxy-0.1.0/src/cachellm/embeddings/fastembed_backend.py +75 -0
  28. cachellm_proxy-0.1.0/src/cachellm/embeddings/hash_backend.py +42 -0
  29. cachellm_proxy-0.1.0/src/cachellm/errors.py +72 -0
  30. cachellm_proxy-0.1.0/src/cachellm/logging_setup.py +56 -0
  31. cachellm_proxy-0.1.0/src/cachellm/models.py +181 -0
  32. cachellm_proxy-0.1.0/src/cachellm/observability/__init__.py +6 -0
  33. cachellm_proxy-0.1.0/src/cachellm/observability/metrics.py +147 -0
  34. cachellm_proxy-0.1.0/src/cachellm/observability/tracing.py +107 -0
  35. cachellm_proxy-0.1.0/src/cachellm/pricing.py +108 -0
  36. cachellm_proxy-0.1.0/src/cachellm/providers/__init__.py +7 -0
  37. cachellm_proxy-0.1.0/src/cachellm/providers/base.py +84 -0
  38. cachellm_proxy-0.1.0/src/cachellm/providers/bedrock.py +238 -0
  39. cachellm_proxy-0.1.0/src/cachellm/providers/fake.py +56 -0
  40. cachellm_proxy-0.1.0/src/cachellm/providers/openai_compat.py +131 -0
  41. cachellm_proxy-0.1.0/src/cachellm/providers/registry.py +96 -0
  42. cachellm_proxy-0.1.0/src/cachellm/py.typed +0 -0
  43. cachellm_proxy-0.1.0/src/cachellm/settings.py +230 -0
@@ -0,0 +1,550 @@
1
+ Metadata-Version: 2.4
2
+ Name: cachellm-proxy
3
+ Version: 0.1.0
4
+ Summary: A drop-in semantic cache for OpenAI-compatible LLM APIs: match requests by meaning, serve repeats instantly, cut spend.
5
+ Keywords: llm,cache,semantic-cache,openai,bedrock,proxy,vector-search,redis,fastapi,llmops,cost-optimization
6
+ Author: Adarsh Dwivedi
7
+ Author-email: Adarsh Dwivedi <adarshdwivedi256@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: boto3>=1.43.91
18
+ Requires-Dist: fastapi>=0.141.1
19
+ Requires-Dist: fastembed>=0.8.0
20
+ Requires-Dist: httpx>=0.28.1
21
+ Requires-Dist: numpy>=2.4.6
22
+ Requires-Dist: openai>=3.11.0
23
+ Requires-Dist: prometheus-client>=0.26.0
24
+ Requires-Dist: pydantic-settings>=2.15.0
25
+ Requires-Dist: redis>=8.1.0
26
+ Requires-Dist: redisvl>=0.27.1
27
+ Requires-Dist: structlog>=26.1.0
28
+ Requires-Dist: typer>=0.27.2
29
+ Requires-Dist: uvicorn[standard]>=0.52.4
30
+ Requires-Dist: botocore[crt]>=1.43.91 ; extra == 'aws'
31
+ Requires-Dist: datasets>=5.0.1 ; extra == 'datasets'
32
+ Requires-Dist: pandas>=3.0.5 ; extra == 'datasets'
33
+ Requires-Dist: langfuse>=4.15.2 ; extra == 'observability'
34
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.44.0 ; extra == 'observability'
35
+ Requires-Dist: opentelemetry-instrumentation-botocore>=0.65b0 ; extra == 'observability'
36
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.65b0 ; extra == 'observability'
37
+ Requires-Dist: opentelemetry-instrumentation-httpx>=0.65b0 ; extra == 'observability'
38
+ Requires-Dist: opentelemetry-instrumentation-redis>=0.65b0 ; extra == 'observability'
39
+ Requires-Dist: opentelemetry-sdk>=1.44.0 ; extra == 'observability'
40
+ Requires-Python: >=3.11
41
+ Project-URL: Homepage, https://github.com/adarshcod30/CacheLLM
42
+ Project-URL: Repository, https://github.com/adarshcod30/CacheLLM
43
+ Project-URL: Issues, https://github.com/adarshcod30/CacheLLM/issues
44
+ Provides-Extra: aws
45
+ Provides-Extra: datasets
46
+ Provides-Extra: observability
47
+ Description-Content-Type: text/markdown
48
+
49
+ # CacheLLM
50
+
51
+ [![CI](https://github.com/adarshcod30/CacheLLM/actions/workflows/ci.yml/badge.svg)](https://github.com/adarshcod30/CacheLLM/actions/workflows/ci.yml)
52
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](https://www.python.org/)
53
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
54
+ [![Tests](https://img.shields.io/badge/tests-128%20passing-brightgreen)](tests/)
55
+ [![PyPI](https://img.shields.io/pypi/v/cachellm-proxy)](https://pypi.org/project/cachellm-proxy/)
56
+ [![Hit rate](https://img.shields.io/badge/hit%20rate-77%25%20on%20Bedrock-orange)](docs/evaluation.md)
57
+
58
+ **A drop-in semantic cache for OpenAI-compatible LLM APIs. Change one base URL, and questions your model has already answered come back in milliseconds instead of seconds.**
59
+
60
+ On a 2,000-request replay against **AWS Bedrock** it served **77% of traffic from cache** with **zero false positives** on genuinely new questions, cutting spend by **78%** and p95 latency from 1,023 ms to **5.7 ms**.
61
+
62
+ [Quick start](#quick-start) · [How it works](#how-it-works) · [Evaluation](docs/evaluation.md) · [API reference](#api-reference) · [Deployment](#deployment-and-infrastructure)
63
+
64
+ ![CacheLLM serving repeated and reworded questions from cache](docs/images/demo.gif)
65
+
66
+ *Real requests against Amazon Nova Micro on AWS Bedrock. A new question takes 1.6 seconds. The same question repeated takes 2.5 ms. A reworded version takes 2.9 ms at 0.99 similarity. A genuinely different question still misses, and anything with personal data in it is never stored.*
67
+
68
+ `llm` `semantic-cache` `openai-compatible` `fastapi` `redis` `vector-search` `aws-bedrock` `llmops` `prometheus` `grafana` `opentelemetry` `cost-optimization`
69
+
70
+ > **Status.** Working software with a real test suite and reproducible numbers. It runs locally or in Docker. There is no hosted demo URL: this is infrastructure you run in front of your own LLM calls, so the [quick start](#quick-start) has it serving traffic in about two minutes.
71
+
72
+ ---
73
+
74
+ ## The problem
75
+
76
+ Every team running LLMs at scale pays twice for the same answer. Users ask the same questions in different words, support bots field the same twenty issues all day, and batch jobs re-classify near-identical rows. Each of those is a fresh API call: real money, and one to three seconds a user waits.
77
+
78
+ A normal cache does not help. Change one letter and the key misses, so exact-match caching catches almost nothing in natural language.
79
+
80
+ CacheLLM caches by **meaning**. It embeds each prompt, searches for the nearest answer it has already paid for, and serves it when the match is close enough. Your application changes one line:
81
+
82
+ ```python
83
+ client = OpenAI(base_url="http://localhost:8080/v1") # was https://api.openai.com/v1
84
+ ```
85
+
86
+ Everything else stays the same: same request shape, same response shape, same errors, same streaming. Responses carry `X-Cache` headers so you can see exactly what happened.
87
+
88
+ ## Headline results
89
+
90
+ 2,000 requests at concurrency 8 against **Amazon Nova Micro on AWS Bedrock**, from a laptop in India. Full method, caveats and reproduction steps in [docs/evaluation.md](docs/evaluation.md).
91
+
92
+ | Metric | Result |
93
+ | --- | ---: |
94
+ | Hit rate | **77.0%** (ceiling for this workload: 79.6%) |
95
+ | False positives on genuinely new questions | **0 of 368** |
96
+ | Reworded repeats served from cache | 95.2% |
97
+ | Exact repeats served from cache | 99.3% |
98
+ | p95 latency, cache hit | **5.7 ms** |
99
+ | p95 latency, cache miss | 1,022.8 ms |
100
+ | p95 speedup | **178.8x** |
101
+ | Cost reduction | **78.0%** |
102
+ | Throughput | 41.9 req/s |
103
+ | Errors | 0 |
104
+
105
+ Hit rate climbed from 44% in the first hundred requests to 87% in the last hundred as the cache warmed. The whole run cost **$0.0038** in real Bedrock charges, because 1,540 of the 2,000 requests never reached the model.
106
+
107
+ The same benchmark runs without any cloud credentials against the built-in fake provider, and lands within half a point: 77.2% hit rate, again with zero false positives. That version is what CI asserts on every push.
108
+
109
+ ## Key features
110
+
111
+ | Feature | What it does | Why it exists |
112
+ | --- | --- | --- |
113
+ | **Drop-in OpenAI API** | Same request and response shape, streaming included, verified against the official `openai` Python SDK in CI | Adoption has to cost one line, or nobody adopts it |
114
+ | **Two-tier cache** | Exact-match tier answers literal repeats in about a millisecond without embedding; semantic tier handles rewording | 82% of hits came from the exact tier: free, fast and impossible to get semantically wrong |
115
+ | **Per-model threshold calibration** | Ships measured safe thresholds for six embedding models and picks the right one automatically | Measured safe thresholds span 0.89 to 0.98. A threshold copied between models is a guess |
116
+ | **Cacheability policy** | Classifies every prompt and decides cacheable, category, TTL | Creative writing, live data and personal questions must not be cached like a fact |
117
+ | **Personal-data guard** | Refuses to store prompts containing emails, long digit runs, API keys or "my order" phrasing | Serving one user's answer to another is the failure that gets a cache torn out |
118
+ | **Shadow mode** | Logs what the cache would have served, then calls the provider anyway | Lets a team watch it on real traffic for a week before trusting it |
119
+ | **Namespace isolation** | System prompt, model, provider, temperature and max tokens all fold into the cache key | Two features sharing a proxy must never share answers |
120
+ | **Targeted invalidation** | Drop a namespace or a model with one call, backed by an index lookup rather than a keyspace scan | "The system prompt changed" and "we upgraded the model" are routine events |
121
+ | **Stampede protection** | Identical concurrent misses collapse into a single upstream call | Ten users asking one new question should cost one generation, not ten |
122
+ | **Streaming both ways** | Misses stream through while buffering; hits replay as a stream | A cached answer must not break a client that asked for a stream |
123
+ | **Fails open** | If Redis or the embedder dies, it forwards everything upstream and reports itself degraded | A cache that takes your app down is worse than no cache |
124
+ | **Near-miss analysis** | Records every lookup that landed just below the threshold, with a cumulative histogram | Tune the threshold on your own traffic instead of guessing |
125
+ | **Threshold sweep endpoint** | Score your own labelled pairs and see the precision and recall tradeoff | The tuning question, answered with your data |
126
+ | **Full observability** | Prometheus metrics, a provisioned Grafana dashboard, structured logs, optional OpenTelemetry to Langfuse | Nobody leaves a cache on that they cannot see |
127
+
128
+ ## Tech stack
129
+
130
+ | Layer | Choice | Why this one |
131
+ | --- | --- | --- |
132
+ | Language | Python 3.11+ | Where the LLM ecosystem lives |
133
+ | Packaging | `uv` | Fast resolution, a real lockfile, reproducible in CI |
134
+ | API | FastAPI + Uvicorn | Async, native streaming, OpenAPI docs for free |
135
+ | Validation and config | Pydantic v2, pydantic-settings | Typed request shapes and typed configuration from the environment |
136
+ | Embeddings | fastembed, `all-MiniLM-L6-v2` (ONNX, CPU) | In-process and about 6 ms. A hosted embedding API would put 100 ms in front of every cache hit and defeat the point |
137
+ | Vector store | Redis 8 + RedisVL, HNSW over cosine | One process is the exact-match store, the vector index and the stampede lock. Sub-millisecond, and TTL expiry removes entries from the index for free |
138
+ | Providers | AWS Bedrock (Converse), any OpenAI-compatible endpoint, deterministic fake | Converse reaches Nova, Claude, Llama and Mistral with one request shape and no extra vendor keys |
139
+ | Metrics | prometheus-client, Prometheus, Grafana | Dashboard ships provisioned, so a reviewer sees data on first boot |
140
+ | Tracing | OpenTelemetry, optional, GenAI semantic conventions | Instrument once, export to Langfuse, Tempo or Jaeger |
141
+ | Logging | structlog, JSON, prompts redacted by default | A proxy sees every question every user asks |
142
+ | Testing | pytest, 126 tests, real Redis in CI | Including the official OpenAI SDK driving the proxy |
143
+ | Quality | ruff, mypy strict-ish, GitHub Actions across Python 3.11, 3.12, 3.13 | |
144
+ | Containers | Docker multi-stage, Docker Compose | Model baked into the image so a cold container does not download 90 MB on its first request |
145
+
146
+ ## How it works
147
+
148
+ ### System architecture
149
+
150
+ ```mermaid
151
+ flowchart TB
152
+ App["Your application<br/>(OpenAI SDK, LangChain,<br/>Open WebUI, curl)"]
153
+
154
+ subgraph Proxy["CacheLLM proxy (FastAPI)"]
155
+ direction TB
156
+ Auth["Auth + policy<br/>cacheable? category? TTL?"]
157
+ L1["Tier 1: exact match<br/>normalised hash"]
158
+ Embed["Embedder<br/>MiniLM ONNX, in process"]
159
+ L2["Tier 2: semantic search<br/>HNSW, cosine, namespace filtered"]
160
+ Flight["Single-flight<br/>stampede guard"]
161
+ Router["Provider router"]
162
+ Auth --> L1 --> Embed --> L2 --> Flight --> Router
163
+ end
164
+
165
+ subgraph Redis["Redis 8"]
166
+ Keys["Exact keys<br/>hash to entry id"]
167
+ Index["Vector index<br/>entry + embedding + TTL"]
168
+ Stats["Counters and<br/>near-miss log"]
169
+ end
170
+
171
+ subgraph Providers["Upstream models"]
172
+ Bedrock["AWS Bedrock<br/>Nova, Claude, Llama, Mistral"]
173
+ OpenAICompat["OpenAI-compatible<br/>OpenAI, Groq, vLLM, Together"]
174
+ end
175
+
176
+ subgraph Obs["Observability"]
177
+ Prom["Prometheus"]
178
+ Graf["Grafana"]
179
+ Otel["OpenTelemetry<br/>to Langfuse or Tempo"]
180
+ end
181
+
182
+ App -->|"POST /v1/chat/completions"| Auth
183
+ Proxy -.-> Redis
184
+ Router --> Bedrock
185
+ Router --> OpenAICompat
186
+ Proxy -->|"/metrics"| Prom --> Graf
187
+ Proxy -.->|"spans"| Otel
188
+ Proxy -->|"response + X-Cache headers"| App
189
+ ```
190
+
191
+ **In plain language.** Your app talks to CacheLLM exactly as it would talk to OpenAI. The proxy first decides whether this request may be cached at all. If it may, it tries a cheap exact-match lookup. Failing that, it embeds the prompt locally and asks Redis for the nearest answer it has already paid for, but only among answers allowed to serve this system prompt and model. If nothing is close enough, it forwards to the real provider, streams the answer back, and stores it for next time. Every step increments a metric, so hit rate and money saved are visible live.
192
+
193
+ ### Request flow
194
+
195
+ ```mermaid
196
+ sequenceDiagram
197
+ autonumber
198
+ participant C as Client
199
+ participant P as CacheLLM
200
+ participant E as Embedder
201
+ participant R as Redis
202
+ participant M as Provider
203
+
204
+ C->>P: POST /v1/chat/completions
205
+ P->>P: Cacheable? Temperature, tools, JSON mode,<br/>multi-turn, personal data
206
+ alt Not cacheable
207
+ P->>M: Forward untouched
208
+ M-->>C: Response, X-Cache: BYPASS
209
+ else Cacheable
210
+ P->>R: Tier 1, exact hash lookup
211
+ alt Exact hit
212
+ R-->>P: Stored answer
213
+ P-->>C: Response in about 1 ms, X-Cache: HIT (exact)
214
+ else No exact match
215
+ P->>E: Embed prompt (about 6 ms)
216
+ E-->>P: 384-dim unit vector
217
+ P->>R: KNN inside this namespace
218
+ R-->>P: Nearest neighbours with scores
219
+ alt Best score >= calibrated threshold
220
+ P->>R: Increment hit count
221
+ P-->>C: Response, X-Cache: HIT (semantic), X-Cache-Similarity
222
+ else Below threshold
223
+ P->>R: Record near miss if close
224
+ P->>P: Single-flight: join an identical call in progress?
225
+ P->>M: Call the model
226
+ M-->>P: Answer, tokens, finish reason
227
+ alt Finished cleanly
228
+ P->>R: Store entry with TTL for its category
229
+ end
230
+ P-->>C: Response, X-Cache: MISS
231
+ end
232
+ end
233
+ end
234
+ ```
235
+
236
+ ### Why two tiers
237
+
238
+ The exact tier costs one Redis `GET` and no embedding. Against Bedrock it produced 1,265 of 1,540 hits: 82% of all cache hits, at about 2.6 ms each, with no possibility of a semantic mistake. The semantic tier added another 275 hits, close to 14 points of hit rate, and it is the tier that carries risk. Building the cheap safe tier first is the difference between a demo and something you would deploy.
239
+
240
+ ### What is deliberately not cached
241
+
242
+ Prompts above 0.3 temperature, requests for multiple completions, tool calls, JSON mode, multi-turn conversations, non-text content, prompts over 8,000 characters, and anything that looks like personal data. Each rule maps to a specific way a naive cache goes wrong, each is a flag you can flip, and each shows up in the response as `X-Cache-Bypass-Reason`.
243
+
244
+ ## The evaluation pipeline
245
+
246
+ This is where a caching project usually hand-waves. The full write-up is in [docs/evaluation.md](docs/evaluation.md); here is the shape of it.
247
+
248
+ **Data.** A hand-built labelled corpus in `eval/corpus.py`: 40 question groups covering 120 prompts, plus 35 **hard negatives**. Hard negatives are pairs one word apart with opposite meaning, like "undo the last git commit" against "undo the last git merge". That gives 193 labelled pairs, 123 duplicates and 70 non-duplicates. Any cache scores well on paraphrases alone, so hard negatives are the actual test.
249
+
250
+ **Method.** Embed both sides of every pair, sweep the cosine threshold from 0.70 to 1.00, and report recall, precision, F1 and how many hard negatives got served at each step. Then repeat across six embedding models and score each on the metric that matters: *the most recall you can get while serving zero hard negatives*.
251
+
252
+ **Result 1: one threshold cannot do the job.** With `bge-small`, the model most tutorials reach for, the first threshold that serves zero hard negatives is 0.96, and it catches 8.1% of genuine duplicates. Meanwhile "What is CORS?" and "Explain cross origin resource sharing" score 0.55, while "git commit" against "git merge" scores 0.95. The distributions overlap.
253
+
254
+ **Result 2: thresholds do not transfer between models.** The safe threshold ranges from 0.89 for MiniLM to 0.98 for Arctic-embed. Every model tested had negative separation, meaning the mean duplicate score sat below the worst hard negative. Bigger and slower did not fix it.
255
+
256
+ | Model | Safe threshold | Recall there | Embed ms |
257
+ | --- | ---: | ---: | ---: |
258
+ | `all-MiniLM-L6-v2` | **0.89** | **35.0%** | 5.7 |
259
+ | `gte-base` | 0.96 | 26.0% | 20.6 |
260
+ | `jina-embeddings-v2-small-en` | 0.96 | 16.3% | 1.9 |
261
+ | `bge-base-en-v1.5` | 0.94 | 11.4% | 8.9 |
262
+ | `snowflake-arctic-embed-s` | 0.98 | 11.4% | 3.2 |
263
+ | `bge-small-en-v1.5` | 0.96 | 8.1% | 3.8 |
264
+
265
+ MiniLM gives four times the safe recall of bge-small at a third of the download size, so it is the default. The whole table ships in code as `CALIBRATED_THRESHOLDS`, and the proxy warns at startup if you configure a model it has never measured.
266
+
267
+ **Result 3, a negative one: a lexical guard does not rescue it.** The obvious fix is to require matched prompts to share content words. Measured, hard negatives have *higher* token overlap (0.42 mean) than genuine paraphrases share vocabulary, because they differ by exactly one decisive word. The guard rejects good matches and keeps dangerous ones. It was measured and dropped rather than shipped.
268
+
269
+ **Result 4: the benchmark was flattering itself.** The first load test reported 79.1% and also showed 37 supposedly-new questions hitting the cache. They were not new: the long-tail generator emitted five phrasings per topic, so the "unseen" pool was full of paraphrases of itself. Fixed to one phrasing per topic, cross-matching fell to zero and the headline dropped to 77.2%. The lower number is the honest one.
270
+
271
+ ## Quick start
272
+
273
+ You need Python 3.11 or newer and a Redis 8 instance. Redis 8 is required because the vector index needs the query engine, and it must be database 0 because Redis Search only indexes that one.
274
+
275
+ ```bash
276
+ pip install cachellm-proxy
277
+ ```
278
+
279
+ The distribution is `cachellm-proxy` because PyPI blocks `cachellm` as too close to an existing `cachelm`. The import name and the CLI are both still `cachellm`.
280
+
281
+ Or from source, which is what you want if you plan to change anything:
282
+
283
+ ```bash
284
+ git clone https://github.com/adarshcod30/CacheLLM.git
285
+ cd CacheLLM
286
+ uv sync
287
+ ```
288
+
289
+ Start Redis if you do not have one running:
290
+
291
+ ```bash
292
+ docker run -d --name redis -p 6379:6379 redis:8-alpine
293
+ ```
294
+
295
+ Run the proxy. With no configuration it uses the built-in fake provider, so you can see it working before wiring up a real model or spending anything:
296
+
297
+ ```bash
298
+ CACHELLM_DEFAULT_PROVIDER=fake CACHELLM_FAKE_LATENCY_MS=600 uv run cachellm serve
299
+ ```
300
+
301
+ In another terminal, ask the same thing twice and watch the second one come back instantly:
302
+
303
+ ```bash
304
+ curl -s -D- http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"fake/echo","temperature":0,"messages":[{"role":"user","content":"What is Redis used for?"}]}' | grep -i '^x-cache'
305
+ ```
306
+
307
+ Run it again, then try a reworded version, and compare the headers:
308
+
309
+ ```bash
310
+ curl -s -D- http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"fake/echo","temperature":0,"messages":[{"role":"user","content":"explain what redis does"}]}' | grep -i '^x-cache'
311
+ ```
312
+
313
+ You should see `X-Cache: MISS`, then `HIT` on the exact repeat with `X-Cache-Tier: exact`, then `HIT` on the reworded one with `X-Cache-Tier: semantic` and a similarity score.
314
+
315
+ Point a real client at it:
316
+
317
+ ```python
318
+ from openai import OpenAI
319
+
320
+ client = OpenAI(base_url="http://localhost:8080/v1", api_key="unused")
321
+ response = client.chat.completions.create(
322
+ model="fake/echo",
323
+ temperature=0,
324
+ messages=[{"role": "user", "content": "What is Redis used for?"}],
325
+ )
326
+ print(response.choices[0].message.content)
327
+ ```
328
+
329
+ Run the whole stack, including Prometheus and a pre-provisioned Grafana dashboard:
330
+
331
+ ```bash
332
+ make up
333
+ ```
334
+
335
+ Then open Grafana at `http://localhost:3000` and the API docs at `http://localhost:8080/docs`.
336
+
337
+ Reproduce the numbers in this README:
338
+
339
+ ```bash
340
+ make tune && make compare-models && make bench
341
+ ```
342
+
343
+ ### Using a real provider
344
+
345
+ AWS Bedrock needs no extra vendor keys and reaches Nova, Claude, Llama and Mistral through one API:
346
+
347
+ ```bash
348
+ CACHELLM_DEFAULT_PROVIDER=bedrock AWS_REGION=us-east-1 uv run cachellm serve
349
+ ```
350
+
351
+ If your credentials come from `aws login` rather than static keys or an SSO profile, install the CRT extra once, because that credential provider needs it:
352
+
353
+ ```bash
354
+ uv sync --extra aws
355
+ ```
356
+
357
+ The proxy detects that case and says so in the error rather than passing along boto's version of the message.
358
+
359
+ ```bash
360
+ curl -s http://localhost:8080/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"bedrock/us.amazon.nova-micro-v1:0","temperature":0,"messages":[{"role":"user","content":"What is Redis used for?"}]}'
361
+ ```
362
+
363
+ Any OpenAI-compatible endpoint works too, which covers OpenAI, Groq, Together, OpenRouter and a local vLLM:
364
+
365
+ ```bash
366
+ CACHELLM_DEFAULT_PROVIDER=openai CACHELLM_OPENAI_BASE_URL=https://api.groq.com/openai/v1 CACHELLM_OPENAI_API_KEY=... uv run cachellm serve
367
+ ```
368
+
369
+ ## API reference
370
+
371
+ ### Chat completions
372
+
373
+ `POST /v1/chat/completions` accepts the OpenAI chat completions body unchanged, streaming included. Unknown fields pass through rather than erroring, because OpenAI adds parameters regularly and a proxy that rejects them is not a drop-in.
374
+
375
+ Every response carries headers explaining the decision:
376
+
377
+ | Header | Meaning |
378
+ | --- | --- |
379
+ | `X-Cache` | `HIT`, `MISS`, `BYPASS` or `SHADOW` |
380
+ | `X-Cache-Tier` | `exact` or `semantic` |
381
+ | `X-Cache-Similarity` | Cosine similarity of the match |
382
+ | `X-Cache-Threshold` | Threshold in force for this category |
383
+ | `X-Cache-Category` | `factual`, `classification`, `creative`, `volatile`, `conversational` |
384
+ | `X-Cache-Bypass-Reason` | Why it was not cached, when it was not |
385
+ | `X-Cache-Saved-USD` | Modelled money this hit avoided |
386
+ | `X-Cache-Age-Seconds` | How old the served entry is |
387
+ | `X-Cache-Lookup-Ms`, `X-Cache-Latency-Ms` | Lookup time and total time |
388
+ | `X-Cache-Coalesced` | Present when this request joined an in-flight identical call |
389
+
390
+ Clients can steer per request with `X-Cache-Control`:
391
+
392
+ | Value | Effect |
393
+ | --- | --- |
394
+ | `no-store` or `no-cache` | Skip the cache entirely for this request |
395
+ | `only-if-cached` | Return 504 rather than calling the provider on a miss |
396
+
397
+ ### Operator endpoints
398
+
399
+ | Endpoint | Purpose |
400
+ | --- | --- |
401
+ | `GET /admin/stats` | Hit rate, tier split, money saved, latency percentiles, entry count |
402
+ | `GET /admin/config` | Effective thresholds, TTLs and rules |
403
+ | `POST /admin/invalidate` | Drop by `namespace`, by `model`, or `all` |
404
+ | `GET /admin/near-misses` | Recent lookups that landed just below threshold |
405
+ | `GET /admin/near-miss-histogram` | Cumulative view of what a lower threshold would buy |
406
+ | `GET /admin/entries` | Inspect what is stored |
407
+ | `POST /admin/threshold-sweep` | Score your own labelled pairs across thresholds |
408
+ | `POST /admin/reset-stats` | Clear counters |
409
+ | `GET /metrics` | Prometheus exposition |
410
+ | `GET /healthz`, `GET /readyz` | Liveness, and readiness that reports degraded rather than failing |
411
+
412
+ Tune the threshold against your own traffic:
413
+
414
+ ```bash
415
+ curl -s http://localhost:8080/admin/threshold-sweep -H 'Content-Type: application/json' -d '{"pairs":[{"a":"how do I reset my password","b":"password reset steps","duplicate":true},{"a":"how do I enable 2FA","b":"how do I disable 2FA","duplicate":false}]}'
416
+ ```
417
+
418
+ ### Command line
419
+
420
+ ```bash
421
+ uv run cachellm serve # run the proxy
422
+ uv run cachellm stats # cache statistics
423
+ uv run cachellm config # effective configuration
424
+ uv run cachellm invalidate --all
425
+ uv run cachellm tune pairs.jsonl
426
+ ```
427
+
428
+ ## Configuration
429
+
430
+ Every setting is an environment variable prefixed `CACHELLM_`, or a line in `.env`. Copy `.env.example` to start. The ones that matter most:
431
+
432
+ | Variable | Default | Notes |
433
+ | --- | --- | --- |
434
+ | `CACHELLM_REDIS_URL` | `redis://localhost:6379/0` | Must be database 0. Redis Search cannot index any other |
435
+ | `CACHELLM_API_KEYS` | empty | Comma-separated client keys. Empty disables auth, which is local development only |
436
+ | `CACHELLM_EMBEDDING_MODEL` | `all-MiniLM-L6-v2` | Changing this changes the safe threshold. See the calibration table |
437
+ | `CACHELLM_THRESHOLD_*` | `0` | Zero means use the calibrated value for your model |
438
+ | `CACHELLM_SHADOW_MODE` | `false` | Observe-only. Turn this on first |
439
+ | `CACHELLM_TTL_FACTUAL` | `604800` | Seven days for stable facts |
440
+ | `CACHELLM_TTL_VOLATILE` | `900` | Fifteen minutes for anything about now |
441
+ | `CACHELLM_MAX_CACHEABLE_TEMPERATURE` | `0.3` | Above this, nothing is cached |
442
+ | `CACHELLM_PII_GUARD` | `true` | Refuse to store prompts that look personal |
443
+ | `CACHELLM_DEFAULT_PROVIDER` | `bedrock` | `bedrock`, `openai` or `fake` |
444
+ | `CACHELLM_LOG_PROMPTS` | `false` | Prompt text stays out of logs unless you opt in |
445
+
446
+ ## Deployment and infrastructure
447
+
448
+ **Local.** `make up` runs proxy, Redis, Prometheus and Grafana with the dashboard already provisioned. The image bakes the embedding model in, so a cold container does not spend its first request downloading it.
449
+
450
+ **One box.** The proxy is a single stateless container plus Redis. It is comfortable on a 1 GB instance: the embedding model uses about 100 MB of RAM and the cache size is your choice. Memory sizing is roughly 1 KB per entry for a 384-dimensional vector plus the stored answer, so 100,000 entries fits in well under a gigabyte. Redis is configured with `allkeys-lru` so it degrades by evicting rather than by failing.
451
+
452
+ **Scaling out.** Run several proxy replicas against one Redis. All state lives in Redis, so replicas are interchangeable. The stampede guard is per process, so N replicas can produce up to N duplicate calls for the same brand-new prompt, which is still far better than uncoordinated.
453
+
454
+ **CI/CD.** GitHub Actions runs lint, mypy, and the test suite against a real Redis 8 service container across Python 3.11, 3.12 and 3.13. A separate job boots the proxy, replays 300 requests and fails the build if hit rate collapses or if any genuinely-new question gets a cache hit. A fourth job builds the Docker image and boots it against Redis to check it comes up healthy.
455
+
456
+ **Monitoring.** Prometheus scrapes `/metrics` every five seconds.
457
+
458
+ ![The shipped Grafana dashboard, populated by real Bedrock traffic](docs/images/dashboard.png)
459
+
460
+ *The dashboard that ships with the repo, filled by 2,500 real requests through Bedrock. Latency is on a log axis because hits and misses are three orders of magnitude apart, which a linear axis would flatten into a single line at zero.*
461
+ The shipped Grafana dashboard has eleven panels: cumulative money saved, cost reduction, hit rate, cache size, latency by outcome on a log scale, request rate by outcome, similarity distributions for hits against misses, hits by category, near misses and stampedes, tokens spent against avoided, and embedding time. Set `CACHELLM_TRACING_ENABLED` with an OTLP endpoint to send traces to Langfuse, Tempo or Jaeger.
462
+
463
+ **Rolling it out safely.** Turn on shadow mode, leave it for a week, read `/admin/near-misses` and `/admin/stats`, run `/admin/threshold-sweep` on pairs drawn from your own logs, then switch shadow mode off.
464
+
465
+ ## Project structure
466
+
467
+ ```
468
+ cachellm/
469
+ ├── src/cachellm/
470
+ │ ├── settings.py # typed config, calibrated thresholds per model
471
+ │ ├── models.py # OpenAI-shaped request and response schemas
472
+ │ ├── pricing.py # token prices, overridable, drives money saved
473
+ │ ├── errors.py # OpenAI-shaped error envelope
474
+ │ ├── cli.py # serve, stats, config, invalidate, tune
475
+ │ ├── api/
476
+ │ │ ├── app.py # app assembly, health, metrics, error handlers
477
+ │ │ ├── routes_chat.py # the drop-in endpoint, streaming both ways
478
+ │ │ ├── routes_admin.py # stats, invalidation, near misses, sweep
479
+ │ │ ├── deps.py # app state, fail-open wiring
480
+ │ │ ├── auth.py # constant-time key check
481
+ │ │ └── sse.py # server-sent event framing
482
+ │ ├── cache/
483
+ │ │ ├── keys.py # normalisation, namespace, exact hash
484
+ │ │ ├── policy.py # cacheable? category? TTL? personal data?
485
+ │ │ ├── exact_store.py # tier 1
486
+ │ │ ├── vector_store.py # tier 2, HNSW index and invalidation
487
+ │ │ ├── service.py # lookup, store, invalidate, stats
488
+ │ │ ├── coalesce.py # single-flight stampede guard
489
+ │ │ ├── analytics.py # durable counters and the near-miss log
490
+ │ │ └── entry.py # the stored record
491
+ │ ├── embeddings/ # fastembed backend, hashing backend for tests
492
+ │ ├── providers/ # base, bedrock, openai-compatible, fake, router
493
+ │ └── observability/ # prometheus metrics, optional OpenTelemetry
494
+ ├── tests/ # 126 tests, unit and integration
495
+ ├── bench/
496
+ │ ├── workload.py # realistic request mix, Zipf popularity
497
+ │ ├── replay.py # the load test
498
+ │ ├── tune_threshold.py # offline threshold sweep
499
+ │ └── compare_models.py # six embedding models scored on safe recall
500
+ ├── eval/corpus.py # labelled paraphrases and hard negatives
501
+ ├── dashboards/cachellm.json # provisioned Grafana dashboard
502
+ ├── deploy/ # Prometheus and Grafana provisioning
503
+ ├── docs/evaluation.md # full method, results and negative results
504
+ ├── results/ # measured output behind every number here
505
+ ├── compose.yaml
506
+ └── Dockerfile
507
+ ```
508
+
509
+ ## Testing
510
+
511
+ ```bash
512
+ make test # 126 tests
513
+ make test-cov # with coverage
514
+ make lint # ruff and mypy
515
+ ```
516
+
517
+ The suite covers cache key derivation and namespace isolation, every cacheability rule, the stampede guard under concurrency, Bedrock request translation, lossless SSE framing, and end-to-end cache behaviour against a real Redis. The compatibility test drives the proxy with the official `openai` Python SDK, non-streaming and streaming, which is what caught a bug where replayed cache hits silently dropped a space at every chunk boundary.
518
+
519
+ Tests use a deterministic hashing embedder, so CI needs no model download and results are identical on every machine.
520
+
521
+ ## Roadmap
522
+
523
+ - **Verification pass on semantic hits.** The measured finding is that no embedding model separates paraphrases from one-word-flipped opposites. The honest fix is a cheap second opinion: ask a small model whether the candidate and the incoming prompt are the same question, and only then serve. It costs a fraction of a generation and would let the threshold drop a long way.
524
+ - **Cross-process stampede protection** using a Redis lock, so replicas coordinate.
525
+ - **In-process library mode**, wrapping an OpenAI client directly for people who do not want to run a service.
526
+ - **Per-tenant namespaces** with a scope header, so multi-tenant apps can cache safely.
527
+ - **Embeddings pass-through** so `/v1/embeddings` can be cached too.
528
+ - **Quora Question Pairs at scale** for a 150,000-pair evaluation alongside the hand-built corpus.
529
+
530
+ ## Releasing
531
+
532
+ Tagging a version publishes to PyPI through Trusted Publishing, so no API token
533
+ exists anywhere. See [docs/publishing.md](docs/publishing.md).
534
+
535
+ ```bash
536
+ git tag v0.1.0 && git push origin v0.1.0
537
+ ```
538
+
539
+ ## Contributing
540
+
541
+ Issues and pull requests are welcome. Please run `make lint && make test` before opening one. If you change anything that touches matching quality, include a threshold sweep in the description: the numbers matter more than the argument.
542
+
543
+ ## License
544
+
545
+ MIT. See [LICENSE](LICENSE).
546
+
547
+ ## Contact
548
+
549
+ Adarsh Dwivedi
550
+ [GitHub](https://github.com/adarshcod30) · adarshdwivedi256@gmail.com