semcacher 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.
- semcacher-0.1.0/.github/workflows/ci.yml +39 -0
- semcacher-0.1.0/.github/workflows/release.yml +67 -0
- semcacher-0.1.0/.gitignore +15 -0
- semcacher-0.1.0/CHANGELOG.md +31 -0
- semcacher-0.1.0/CONTRIBUTING.md +27 -0
- semcacher-0.1.0/LICENSE +21 -0
- semcacher-0.1.0/PKG-INFO +375 -0
- semcacher-0.1.0/README.md +321 -0
- semcacher-0.1.0/RELEASING.md +65 -0
- semcacher-0.1.0/assets/icon.svg +15 -0
- semcacher-0.1.0/assets/logo.svg +21 -0
- semcacher-0.1.0/examples/anthropic_client.py +28 -0
- semcacher-0.1.0/examples/decorator.py +18 -0
- semcacher-0.1.0/examples/openai_client.py +30 -0
- semcacher-0.1.0/examples/quickstart.py +26 -0
- semcacher-0.1.0/examples/tuning_pairs.csv +21 -0
- semcacher-0.1.0/pyproject.toml +56 -0
- semcacher-0.1.0/src/semcache/__init__.py +45 -0
- semcacher-0.1.0/src/semcache/__main__.py +5 -0
- semcacher-0.1.0/src/semcache/cache.py +406 -0
- semcacher-0.1.0/src/semcache/cli.py +134 -0
- semcacher-0.1.0/src/semcache/embedders.py +177 -0
- semcacher-0.1.0/src/semcache/integrations.py +233 -0
- semcacher-0.1.0/src/semcache/pricing.py +102 -0
- semcacher-0.1.0/src/semcache/py.typed +0 -0
- semcacher-0.1.0/src/semcache/redis_store.py +373 -0
- semcacher-0.1.0/src/semcache/sqlite_store.py +202 -0
- semcacher-0.1.0/src/semcache/stats.py +156 -0
- semcacher-0.1.0/src/semcache/store.py +204 -0
- semcacher-0.1.0/src/semcache/tuning.py +273 -0
- semcacher-0.1.0/tests/test_cache.py +92 -0
- semcacher-0.1.0/tests/test_call_helpers.py +109 -0
- semcacher-0.1.0/tests/test_cli.py +62 -0
- semcacher-0.1.0/tests/test_embedders.py +54 -0
- semcacher-0.1.0/tests/test_integrations.py +156 -0
- semcacher-0.1.0/tests/test_persistence.py +108 -0
- semcacher-0.1.0/tests/test_pricing.py +32 -0
- semcacher-0.1.0/tests/test_redis_store.py +176 -0
- semcacher-0.1.0/tests/test_stats.py +73 -0
- semcacher-0.1.0/tests/test_tuning.py +145 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: pip install -e ".[dev]"
|
|
21
|
+
- run: ruff check .
|
|
22
|
+
- run: ruff format --check .
|
|
23
|
+
- run: pytest -q
|
|
24
|
+
- run: python examples/quickstart.py
|
|
25
|
+
|
|
26
|
+
build:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v5
|
|
30
|
+
- uses: actions/setup-python@v6
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.12"
|
|
33
|
+
- run: pip install build twine
|
|
34
|
+
- run: python -m build
|
|
35
|
+
- run: twine check --strict dist/*
|
|
36
|
+
- uses: actions/upload-artifact@v5
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishing a GitHub Release (tag vX.Y.Z) uploads that version to PyPI.
|
|
4
|
+
# Running this workflow by hand uploads to TestPyPI instead, as a dry run.
|
|
5
|
+
# Uses PyPI Trusted Publishing: no API token is stored in the repository.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v5
|
|
17
|
+
- uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- run: pip install build twine
|
|
21
|
+
- name: Check the release tag matches the package version
|
|
22
|
+
if: github.event_name == 'release'
|
|
23
|
+
run: |
|
|
24
|
+
version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/semcache/__init__.py)
|
|
25
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
26
|
+
if [ "$tag" != "$version" ]; then
|
|
27
|
+
echo "::error::release tag $GITHUB_REF_NAME does not match __version__ = $version"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
- run: python -m build
|
|
31
|
+
- run: twine check --strict dist/*
|
|
32
|
+
- uses: actions/upload-artifact@v5
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
testpypi:
|
|
38
|
+
if: github.event_name == 'workflow_dispatch'
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment: testpypi
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@v5
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
50
|
+
with:
|
|
51
|
+
repository-url: https://test.pypi.org/legacy/
|
|
52
|
+
|
|
53
|
+
pypi:
|
|
54
|
+
if: github.event_name == 'release'
|
|
55
|
+
needs: build
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
environment:
|
|
58
|
+
name: pypi
|
|
59
|
+
url: https://pypi.org/project/semcacher/
|
|
60
|
+
permissions:
|
|
61
|
+
id-token: write
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/download-artifact@v5
|
|
64
|
+
with:
|
|
65
|
+
name: dist
|
|
66
|
+
path: dist/
|
|
67
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `RedisStore` (`pip install "semcacher[redis]"`): keep the cache in Redis so several
|
|
11
|
+
processes or servers share answers and stats. Works with any Redis-compatible
|
|
12
|
+
server; stats from all processes add up instead of overwriting each other.
|
|
13
|
+
|
|
14
|
+
## [0.1.0] - 2026-11-09
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- First release on PyPI: `pip install semcacher` (import name `semcache`).
|
|
18
|
+
- Supports Python 3.10 and later.
|
|
19
|
+
- `SemanticCache` with exact-match fast path and cosine-similarity lookup.
|
|
20
|
+
- Embedders: dependency-free `HashingEmbedder`, `SentenceTransformerEmbedder`,
|
|
21
|
+
`OpenAIEmbedder`, and `CallableEmbedder` for anything else.
|
|
22
|
+
- `InMemoryStore` and persistent `SQLiteStore`.
|
|
23
|
+
- Namespace and model isolation, TTL expiry, LRU `max_entries` limit.
|
|
24
|
+
- Stats: hit rate, exact vs semantic hits, tokens saved, money saved and spent,
|
|
25
|
+
per-model breakdown; persisted with `SQLiteStore`.
|
|
26
|
+
- `PriceBook` with bundled Claude list prices and custom prices.
|
|
27
|
+
- `get_or_call`, `aget_or_call` and the `@cache.cached` decorator (sync and async).
|
|
28
|
+
- `wrap_openai` and `wrap_anthropic` drop-in client wrappers.
|
|
29
|
+
- `semcache` CLI: `stats`, `list`, `clear`, `prune`, `prices`.
|
|
30
|
+
- `semcache.tuning`: `calibrate()` recommends a similarity threshold from labelled
|
|
31
|
+
prompt pairs, and `load_pairs()` reads them from CSV.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping out!
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
git clone https://github.com/sjapanjots/Semantic_Cache_LLM
|
|
7
|
+
cd Semantic_Cache_LLM
|
|
8
|
+
python -m venv .venv && source .venv/bin/activate
|
|
9
|
+
pip install -e ".[dev]"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Before opening a pull request:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
ruff check . && ruff format --check .
|
|
16
|
+
pytest -q
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Guidelines:
|
|
20
|
+
|
|
21
|
+
- Keep the core dependency-free apart from `numpy`. Provider SDKs and model libraries
|
|
22
|
+
belong behind optional extras and lazy imports.
|
|
23
|
+
- Add a test for every behaviour change.
|
|
24
|
+
- Note user-visible changes in `CHANGELOG.md`.
|
|
25
|
+
|
|
26
|
+
To add a new storage back-end, subclass `semcache.BaseStore`. To add an embedder,
|
|
27
|
+
implement `name` and `embed(texts) -> np.ndarray` returning L2-normalised rows.
|
semcacher-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Japanjot Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
semcacher-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semcacher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A semantic cache for LLM calls: reuse answers to questions that mean the same thing, and track hit rate and money saved.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sjapanjots/Semantic_Cache_LLM
|
|
6
|
+
Project-URL: Issues, https://github.com/sjapanjots/Semantic_Cache_LLM/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/sjapanjots/Semantic_Cache_LLM/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Japanjot Singh
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Japanjot Singh
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: anthropic,cache,claude,cost,embeddings,llm,openai,semantic-cache
|
|
32
|
+
Classifier: Development Status :: 3 - Alpha
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Programming Language :: Python :: 3
|
|
37
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
39
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Requires-Dist: numpy>=1.22
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: fakeredis>=2.20; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
46
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
47
|
+
Provides-Extra: local
|
|
48
|
+
Requires-Dist: sentence-transformers>=2.2; extra == 'local'
|
|
49
|
+
Provides-Extra: openai
|
|
50
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
51
|
+
Provides-Extra: redis
|
|
52
|
+
Requires-Dist: redis>=5; extra == 'redis'
|
|
53
|
+
Description-Content-Type: text/markdown
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<img src="https://raw.githubusercontent.com/sjapanjots/Semantic_Cache_LLM/main/assets/logo.svg" alt="semcache" width="480">
|
|
57
|
+
</p>
|
|
58
|
+
|
|
59
|
+
<p align="center">
|
|
60
|
+
<b>A semantic cache for LLM calls.</b><br>
|
|
61
|
+
If a new question means the same as an old one, return the saved answer.<br>
|
|
62
|
+
Track your hit rate and how much money it saved.
|
|
63
|
+
</p>
|
|
64
|
+
|
|
65
|
+
<p align="center">
|
|
66
|
+
<a href="https://github.com/sjapanjots/Semantic_Cache_LLM/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/sjapanjots/Semantic_Cache_LLM/actions/workflows/ci.yml/badge.svg"></a>
|
|
67
|
+
<img alt="Python" src="https://img.shields.io/badge/python-3.10%2B-blue">
|
|
68
|
+
<img alt="License" src="https://img.shields.io/badge/license-MIT-green">
|
|
69
|
+
</p>
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Why
|
|
74
|
+
|
|
75
|
+
Users ask the same things in different words. An exact-match cache misses
|
|
76
|
+
*"How do I reverse a list in Python?"* vs *"python: how to reverse a list"*, so you pay
|
|
77
|
+
for the same answer twice. `semcache` embeds each prompt, finds the closest one already
|
|
78
|
+
answered, and returns that answer when the two are similar enough. Every hit is counted
|
|
79
|
+
in tokens and dollars, so you can see what the cache is worth.
|
|
80
|
+
|
|
81
|
+
- **Drop-in**: wrap an `OpenAI` or `Anthropic` client, decorate a function, or call
|
|
82
|
+
`get` / `set` yourself.
|
|
83
|
+
- **Shows savings**: hit rate, exact vs semantic hits, tokens saved, money saved and
|
|
84
|
+
spent, broken down per model.
|
|
85
|
+
- **Safe defaults**: answers never cross models, namespaces, system prompts or
|
|
86
|
+
conversation history. Truncated or refused responses are not cached.
|
|
87
|
+
- **Small**: the core needs only `numpy`. Neural embedders are optional extras.
|
|
88
|
+
- **Persistent**: one SQLite file holds entries and stats. TTL and LRU size limits
|
|
89
|
+
are built in.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pip install semcacher # core (numpy only)
|
|
95
|
+
pip install "semcacher[local]" # + sentence-transformers, runs locally
|
|
96
|
+
pip install "semcacher[openai]" # + OpenAI embeddings
|
|
97
|
+
pip install "semcacher[redis]" # + shared cache in Redis
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
You install `semcacher` and import `semcache`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from semcache import SemanticCache
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
From source:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install git+https://github.com/sjapanjots/Semantic_Cache_LLM
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Quickstart
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from semcache import SemanticCache, LLMResult
|
|
116
|
+
|
|
117
|
+
cache = SemanticCache(pricing={"demo-model": (2.50, 10.00)}) # USD per 1M input/output tokens
|
|
118
|
+
|
|
119
|
+
def call_llm(prompt):
|
|
120
|
+
... # your real LLM call
|
|
121
|
+
return LLMResult("answer", input_tokens=1200, output_tokens=400)
|
|
122
|
+
|
|
123
|
+
cache.get_or_call("How do I reverse a list in Python?", lambda: call_llm(...), model="demo-model")
|
|
124
|
+
cache.get_or_call("python: how to reverse a list", lambda: call_llm(...), model="demo-model") # cache hit
|
|
125
|
+
|
|
126
|
+
print(cache.stats)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Output of [`examples/quickstart.py`](examples/quickstart.py), which needs no API key:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
lookups 6
|
|
133
|
+
hits 3 (50.0%)
|
|
134
|
+
exact 1
|
|
135
|
+
semantic 2
|
|
136
|
+
misses 3
|
|
137
|
+
tokens saved 4,800
|
|
138
|
+
money saved $0.0210
|
|
139
|
+
money spent $0.0210
|
|
140
|
+
savings rate 50.0%
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Usage
|
|
144
|
+
|
|
145
|
+
### 1. Wrap your client (no other code changes)
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from anthropic import Anthropic
|
|
149
|
+
from semcache import SemanticCache, SentenceTransformerEmbedder
|
|
150
|
+
from semcache.integrations import wrap_anthropic
|
|
151
|
+
|
|
152
|
+
cache = SemanticCache(embedder=SentenceTransformerEmbedder(), path="cache.db")
|
|
153
|
+
client = wrap_anthropic(Anthropic(), cache)
|
|
154
|
+
|
|
155
|
+
client.messages.create(
|
|
156
|
+
model="claude-opus-5-5",
|
|
157
|
+
max_tokens=1024,
|
|
158
|
+
messages=[{"role": "user", "content": "How should a Python dev learn Rust?"}],
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`wrap_openai(OpenAI(), cache)` does the same for `client.chat.completions.create`.
|
|
163
|
+
Async clients (`AsyncAnthropic`, `AsyncOpenAI`) work too. On a hit you get back the
|
|
164
|
+
SDK's normal response object, rebuilt from the cache.
|
|
165
|
+
|
|
166
|
+
How requests are matched:
|
|
167
|
+
|
|
168
|
+
| Part of the request | Treatment |
|
|
169
|
+
|---|---|
|
|
170
|
+
| Last user message | Compared **by meaning** |
|
|
171
|
+
| Model | Must match exactly |
|
|
172
|
+
| System prompt, earlier turns, tools, temperature, max tokens, ... | Hashed; must match exactly |
|
|
173
|
+
| `timeout`, `extra_headers`, `user`, `metadata` | Ignored |
|
|
174
|
+
| `stream=True` | Passed through, not cached |
|
|
175
|
+
| Responses stopped by `max_tokens` / `refusal` / `length` / `content_filter` | Not cached |
|
|
176
|
+
|
|
177
|
+
### 2. Decorate a function
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
@cache.cached(model="claude-sonnet-5-5")
|
|
181
|
+
def ask(prompt: str) -> LLMResult:
|
|
182
|
+
response = my_llm(prompt)
|
|
183
|
+
return LLMResult(response.text, input_tokens=response.in_tok, output_tokens=response.out_tok)
|
|
184
|
+
|
|
185
|
+
ask("How many days are in a leap year?")
|
|
186
|
+
ask("how many days does a leap year have") # served from the cache
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Works on `async def` functions too. Use `key=lambda *a, **kw: ...` to choose which
|
|
190
|
+
argument is the prompt.
|
|
191
|
+
|
|
192
|
+
### 3. Call `get` / `set` yourself
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
answer = cache.get(prompt, model="claude-opus-5-5")
|
|
196
|
+
if answer is None:
|
|
197
|
+
answer, usage = call_llm(prompt)
|
|
198
|
+
cache.set(prompt, answer, model="claude-opus-5-5",
|
|
199
|
+
input_tokens=usage.input, output_tokens=usage.output)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`cache.lookup(...)` returns a `CacheHit` with `.response`, `.similarity`, `.exact`,
|
|
203
|
+
`.cost_saved` and the stored `.entry`.
|
|
204
|
+
|
|
205
|
+
## Choosing an embedder and threshold
|
|
206
|
+
|
|
207
|
+
The threshold is the minimum cosine similarity for a semantic hit. Each embedder ships
|
|
208
|
+
a conservative default; tune it on your own traffic.
|
|
209
|
+
|
|
210
|
+
| Embedder | Install | Default threshold | Notes |
|
|
211
|
+
|---|---|---|---|
|
|
212
|
+
| `HashingEmbedder` (default) | core | 0.70 | Lexical (words and character n-grams). No model download. Catches rewording, not synonyms. |
|
|
213
|
+
| `SentenceTransformerEmbedder()` | `[local]` | 0.85 | Real semantic matching, runs on CPU. `all-MiniLM-L6-v2` by default. |
|
|
214
|
+
| `OpenAIEmbedder()` | `[openai]` | 0.90 | `text-embedding-3-small` by default. Costs a small amount per lookup. |
|
|
215
|
+
| `CallableEmbedder(fn, name)` | core | 0.85 | Bring any model: Voyage, Cohere, an in-house service... |
|
|
216
|
+
|
|
217
|
+
Too many wrong answers? Raise the threshold. Too few hits? Lower it, or switch to a
|
|
218
|
+
neural embedder. You can also override it per call: `cache.lookup(p, threshold=0.95)`.
|
|
219
|
+
|
|
220
|
+
### Calibrating the threshold from your own data
|
|
221
|
+
|
|
222
|
+
Instead of guessing, label some prompt pairs as same or different meaning and let
|
|
223
|
+
`calibrate` pick the threshold:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from semcache import HashingEmbedder, SemanticCache, calibrate, load_pairs
|
|
227
|
+
|
|
228
|
+
pairs = load_pairs("examples/tuning_pairs.csv") # CSV columns: a,b,same
|
|
229
|
+
result = calibrate(HashingEmbedder(), pairs) # or target_precision=0.99
|
|
230
|
+
print(result.summary())
|
|
231
|
+
cache = SemanticCache(threshold=result.threshold)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
embedder hashing-1024-3
|
|
236
|
+
pairs 20 (10 same, 10 different)
|
|
237
|
+
recommend threshold=0.7099 (best F0.5)
|
|
238
|
+
|
|
239
|
+
threshold precision recall wrong hits F0.5
|
|
240
|
+
0.7000 83.3% 50.0% 1 0.735
|
|
241
|
+
0.7083 83.3% 50.0% 1 0.735
|
|
242
|
+
0.7099 100.0% 50.0% 0 0.833 <-
|
|
243
|
+
0.7100 100.0% 40.0% 0 0.769
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
*Precision* is the share of hits that are correct; *recall* is the share of
|
|
247
|
+
same-meaning pairs that hit. By default the recommendation maximises F0.5, which
|
|
248
|
+
counts a wrong answer as worse than a miss. Pass `target_precision=0.99` to get the
|
|
249
|
+
loosest threshold that is still right 99% of the time. `result.mistakes()` lists the
|
|
250
|
+
pairs it gets wrong. Use the same embedder you'll use in the cache, and aim for at
|
|
251
|
+
least a few dozen pairs drawn from real traffic.
|
|
252
|
+
|
|
253
|
+
## Tracking savings
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
s = cache.stats
|
|
257
|
+
s.hit_rate # 0.42
|
|
258
|
+
s.cost_saved # 18.73 (USD)
|
|
259
|
+
s.cost_spent # 25.90
|
|
260
|
+
s.savings_rate # 0.42 share of the would-be bill avoided
|
|
261
|
+
s.tokens_saved # 3_912_000
|
|
262
|
+
s.by_model["claude-opus-5-5"].hit_rate
|
|
263
|
+
s.to_dict() # for your metrics pipeline
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Money saved is the price of the original call, credited every time its answer is
|
|
267
|
+
reused. Prices come from a `PriceBook`:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from semcache import PriceBook
|
|
271
|
+
|
|
272
|
+
cache = SemanticCache(pricing={"gpt-4o-mini": (0.15, 0.60)}) # merged over defaults
|
|
273
|
+
cache = SemanticCache(pricing=PriceBook(default=(1.0, 3.0))) # fallback for unknown models
|
|
274
|
+
cache.set(prompt, answer, cost=0.0123) # or give the exact cost
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Claude list prices are bundled (run `semcache prices`). Prices change, so check your
|
|
278
|
+
provider's pricing page. Models without a price still count toward hit rate but add
|
|
279
|
+
$0 to savings.
|
|
280
|
+
|
|
281
|
+
## Persistence, expiry and size limits
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
cache = SemanticCache(
|
|
285
|
+
path="cache.db", # SQLite file; entries and stats survive restarts
|
|
286
|
+
ttl=7 * 24 * 3600, # entries expire after a week
|
|
287
|
+
max_entries=50_000, # least recently used entries are evicted
|
|
288
|
+
namespace="support-bot",
|
|
289
|
+
)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- Responses stored in SQLite must be JSON-serialisable. The client wrappers handle
|
|
293
|
+
this for you.
|
|
294
|
+
- A cache file remembers which embedder built it and refuses to open with a
|
|
295
|
+
different one, because their vectors aren't comparable.
|
|
296
|
+
- Use `with SemanticCache(...) as cache:` or call `cache.close()` so the latest stats
|
|
297
|
+
are written.
|
|
298
|
+
|
|
299
|
+
### Sharing one cache between servers (Redis)
|
|
300
|
+
|
|
301
|
+
SQLite keeps the cache on one machine. To let several processes or servers reuse
|
|
302
|
+
each other's answers, keep it in Redis (or any Redis-compatible server such as
|
|
303
|
+
Valkey; no modules needed):
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from semcache import SemanticCache
|
|
307
|
+
from semcache.redis_store import RedisStore
|
|
308
|
+
|
|
309
|
+
cache = SemanticCache(store=RedisStore("redis://localhost:6379/0"))
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- Every process sees the others' answers immediately.
|
|
313
|
+
- Stats are shared too: each process adds its own counts to the totals in Redis,
|
|
314
|
+
so `cache.stats` shows the hit rate and money saved across all of them.
|
|
315
|
+
- `prefix="team-a:"` keeps several caches apart in one database.
|
|
316
|
+
- Search runs in each process: it downloads each vector once, then only fetches
|
|
317
|
+
the list of entry ids per lookup. That is fast up to tens of thousands of
|
|
318
|
+
entries per namespace.
|
|
319
|
+
|
|
320
|
+
## CLI
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
semcache stats --db cache.db # hit rate, tokens and money saved
|
|
324
|
+
semcache stats --db cache.db --json
|
|
325
|
+
semcache list --db cache.db # most-hit entries
|
|
326
|
+
semcache prune --db cache.db --older-than 86400
|
|
327
|
+
semcache clear --db cache.db [--namespace support-bot] [--stats]
|
|
328
|
+
semcache prices # bundled price table
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## How it works
|
|
332
|
+
|
|
333
|
+
```text
|
|
334
|
+
prompt ──► normalise ──► exact match in scope? ──yes──► HIT (similarity 1.0)
|
|
335
|
+
│ no
|
|
336
|
+
▼
|
|
337
|
+
embed ──► nearest neighbour in scope ──► ≥ threshold? ──yes──► HIT
|
|
338
|
+
│ no
|
|
339
|
+
▼
|
|
340
|
+
MISS ──► call LLM ──► set()
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
A *scope* is `namespace + model`; the client wrappers add a hash of the rest of the
|
|
344
|
+
request. Search is an exact cosine scan over a NumPy matrix per scope, fast enough for
|
|
345
|
+
tens of thousands of entries per scope.
|
|
346
|
+
|
|
347
|
+
## When not to use it
|
|
348
|
+
|
|
349
|
+
- Answers that depend on time or live data ("what's the weather now"). Use a `ttl`,
|
|
350
|
+
or don't cache those calls.
|
|
351
|
+
- Prompts where small wording changes matter, such as code with specific values or
|
|
352
|
+
math with different numbers. Use a high threshold or a separate namespace.
|
|
353
|
+
- Personalised answers. Put a user or tenant id in `namespace` so answers never
|
|
354
|
+
leak between users.
|
|
355
|
+
|
|
356
|
+
## Development
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
pip install -e ".[dev]"
|
|
360
|
+
pytest -q
|
|
361
|
+
ruff check . && ruff format --check .
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md).
|
|
365
|
+
|
|
366
|
+
## Roadmap
|
|
367
|
+
|
|
368
|
+
- Pluggable vector indexes (FAISS, hnswlib) for millions of entries
|
|
369
|
+
- Redis and Postgres/pgvector stores
|
|
370
|
+
- Streaming support in the client wrappers
|
|
371
|
+
- Optional LLM-judged verification of borderline hits
|
|
372
|
+
|
|
373
|
+
## License
|
|
374
|
+
|
|
375
|
+
[MIT](LICENSE)
|