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.
- cachellm_proxy-0.1.0/PKG-INFO +550 -0
- cachellm_proxy-0.1.0/README.md +502 -0
- cachellm_proxy-0.1.0/pyproject.toml +173 -0
- cachellm_proxy-0.1.0/pyproject.toml.orig +156 -0
- cachellm_proxy-0.1.0/src/cachellm/__init__.py +13 -0
- cachellm_proxy-0.1.0/src/cachellm/__main__.py +6 -0
- cachellm_proxy-0.1.0/src/cachellm/api/__init__.py +5 -0
- cachellm_proxy-0.1.0/src/cachellm/api/app.py +143 -0
- cachellm_proxy-0.1.0/src/cachellm/api/auth.py +34 -0
- cachellm_proxy-0.1.0/src/cachellm/api/deps.py +106 -0
- cachellm_proxy-0.1.0/src/cachellm/api/routes_admin.py +265 -0
- cachellm_proxy-0.1.0/src/cachellm/api/routes_chat.py +385 -0
- cachellm_proxy-0.1.0/src/cachellm/api/sse.py +98 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/__init__.py +3 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/analytics.py +150 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/coalesce.py +63 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/entry.py +92 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/exact_store.py +33 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/keys.py +124 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/policy.py +134 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/redis_client.py +22 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/service.py +332 -0
- cachellm_proxy-0.1.0/src/cachellm/cache/vector_store.py +217 -0
- cachellm_proxy-0.1.0/src/cachellm/cli.py +122 -0
- cachellm_proxy-0.1.0/src/cachellm/embeddings/__init__.py +19 -0
- cachellm_proxy-0.1.0/src/cachellm/embeddings/base.py +38 -0
- cachellm_proxy-0.1.0/src/cachellm/embeddings/fastembed_backend.py +75 -0
- cachellm_proxy-0.1.0/src/cachellm/embeddings/hash_backend.py +42 -0
- cachellm_proxy-0.1.0/src/cachellm/errors.py +72 -0
- cachellm_proxy-0.1.0/src/cachellm/logging_setup.py +56 -0
- cachellm_proxy-0.1.0/src/cachellm/models.py +181 -0
- cachellm_proxy-0.1.0/src/cachellm/observability/__init__.py +6 -0
- cachellm_proxy-0.1.0/src/cachellm/observability/metrics.py +147 -0
- cachellm_proxy-0.1.0/src/cachellm/observability/tracing.py +107 -0
- cachellm_proxy-0.1.0/src/cachellm/pricing.py +108 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/__init__.py +7 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/base.py +84 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/bedrock.py +238 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/fake.py +56 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/openai_compat.py +131 -0
- cachellm_proxy-0.1.0/src/cachellm/providers/registry.py +96 -0
- cachellm_proxy-0.1.0/src/cachellm/py.typed +0 -0
- 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
|
+
[](https://github.com/adarshcod30/CacheLLM/actions/workflows/ci.yml)
|
|
52
|
+
[](https://www.python.org/)
|
|
53
|
+
[](LICENSE)
|
|
54
|
+
[](tests/)
|
|
55
|
+
[](https://pypi.org/project/cachellm-proxy/)
|
|
56
|
+
[](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
|
+

|
|
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
|
+

|
|
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
|