Metadata-Version: 2.4
Name: sulci
Version: 0.10.2
Summary: The AI native context-aware semantic cache for LLM apps — Patent Pending - stop paying for the same answer twice
Author: Kathiravan Sengodan
License: Apache License
                                   Version 2.0, January 2004
                                http://www.apache.org/licenses/
        
           TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
        
           1. Definitions.
        
              "License" shall mean the terms and conditions for use, reproduction,
              and distribution as defined by Sections 1 through 9 of this document.
        
              "Licensor" shall mean the copyright owner or entity authorized by
              the copyright owner that is granting the License.
        
              "Legal Entity" shall mean the union of the acting entity and all
              other entities that control, are controlled by, or are under common
              control with that entity.
        
              "You" (or "Your") shall mean an individual or Legal Entity
              exercising permissions granted by this License.
        
              "Source" form shall mean the preferred form for making modifications,
              including but not limited to software source code, documentation
              source, and configuration files.
        
              "Object" form shall mean any form resulting from mechanical
              transformation or translation of a Source form, including but
              not limited to compiled object code, generated documentation,
              and conversions to other media types.
        
              "Work" shall mean the work of authorship made available under
              the License, as indicated by a copyright notice that is included
              in or attached to the work.
        
              "Derivative Works" shall mean any work, whether in Source or Object
              form, that is based on (or derived from) the Work.
        
              "Contribution" shall mean any work of authorship submitted to the
              Licensor for inclusion in the Work.
        
              "Contributor" shall mean Licensor and any Legal Entity on behalf
              of whom a Contribution has been received by the Licensor.
        
           2. Grant of Copyright License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              copyright license to reproduce, prepare Derivative Works of,
              publicly display, publicly perform, sublicense, and distribute the
              Work and such Derivative Works in Source or Object form.
        
           3. Grant of Patent License. Subject to the terms and conditions of
              this License, each Contributor hereby grants to You a perpetual,
              worldwide, non-exclusive, no-charge, royalty-free, irrevocable
              (except as stated in this section) patent license to make, have
              made, use, offer to sell, sell, import, and otherwise transfer the
              Work, where such license applies only to those patent claims
              licensable by such Contributor that are necessarily infringed by
              their Contribution(s) alone or by combination of their
              Contributions with the Work.
        
           4. Redistribution. You may reproduce and distribute copies of the
              Work or Derivative Works thereof in any medium, with or without
              modifications, and in Source or Object form, provided that You
              meet the following conditions:
        
              (a) You must give any other recipients of the Work or Derivative
                  Works a copy of this License; and
        
              (b) You must cause any modified files to carry prominent notices
                  stating that You changed the files; and
        
              (c) You must retain, in the Source form of any Derivative Works
                  that You distribute, all copyright, patent, trademark, and
                  attribution notices from the Source form of the Work; and
        
              (d) If the Work includes a "NOTICE" text file, you must include
                  a readable copy of the attribution notices contained within
                  that NOTICE file.
        
           5. Submission of Contributions. Unless You explicitly state otherwise,
              any Contribution submitted for inclusion in the Work shall be
              under the terms of this License.
        
           6. Trademarks. This License does not grant permission to use the
              trade names, trademarks, service marks, or product names of the
              Licensor.
        
           7. Disclaimer of Warranty. Unless required by applicable law or
              agreed to in writing, Licensor provides the Work on an "AS IS"
              BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.
        
           8. Limitation of Liability. In no event shall any Contributor be
              liable to You for damages arising as a result of this License
              or out of the use or inability to use the Work.
        
           9. Accepting Warranty or Additional Liability. While redistributing
              the Work, You may offer acceptance of support, warranty, indemnity,
              or other liability obligations consistent with this License.
        
           END OF TERMS AND CONDITIONS
        
           Copyright 2026 Sulci Labs Inc.
        
           Licensed under the Apache License, Version 2.0 (the "License");
           you may not use this file except in compliance with the License.
           You may obtain a copy of the License at
        
               http://www.apache.org/licenses/LICENSE-2.0
        
           Unless required by applicable law or agreed to in writing, software
           distributed under the License is distributed on an "AS IS" BASIS,
           WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
           implied. See the License for the specific language governing
           permissions and limitations under the License.
        
Project-URL: Homepage, https://sulci.io
Project-URL: Repository, https://github.com/sulci-io/sulci-oss
Project-URL: Documentation, https://github.com/sulci-io/sulci-oss#readme
Project-URL: Changelog, https://github.com/sulci-io/sulci-oss/blob/main/CHANGELOG.md
Project-URL: Bug Tracker, https://github.com/sulci-io/sulci-oss/issues
Keywords: sulci-cache,ai-native,ai-cache,context-aware-semantic-cache,context-aware,semantic-cache,llm,ai,anthropic,openai,vector-search,langchain,cost-optimization,cache,rag
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: httpx>=0.27.0
Provides-Extra: chroma
Requires-Dist: chromadb>=0.4.0; extra == "chroma"
Requires-Dist: sentence-transformers>=2.2.0; extra == "chroma"
Provides-Extra: qdrant
Requires-Dist: qdrant-client>=1.10.0; extra == "qdrant"
Requires-Dist: sentence-transformers>=2.2.0; extra == "qdrant"
Provides-Extra: faiss
Requires-Dist: faiss-cpu>=1.7.4; extra == "faiss"
Requires-Dist: sentence-transformers>=2.2.0; extra == "faiss"
Provides-Extra: redis
Requires-Dist: redis>=5.0.0; extra == "redis"
Requires-Dist: redisvl>=0.1.0; extra == "redis"
Requires-Dist: sentence-transformers>=2.2.0; extra == "redis"
Provides-Extra: sqlite
Requires-Dist: sentence-transformers>=2.2.0; extra == "sqlite"
Provides-Extra: milvus
Requires-Dist: pymilvus>=2.4.10; extra == "milvus"
Requires-Dist: milvus-lite<3,>=2.4.4; sys_platform != "win32" and extra == "milvus"
Requires-Dist: setuptools<82; sys_platform != "win32" and extra == "milvus"
Requires-Dist: grpcio<1.80; (python_version < "3.10" and sys_platform != "win32") and extra == "milvus"
Requires-Dist: sentence-transformers>=2.2.0; extra == "milvus"
Provides-Extra: openai
Requires-Dist: openai>=1.0.0; extra == "openai"
Provides-Extra: cloud
Requires-Dist: httpx>=0.27.0; extra == "cloud"
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
Provides-Extra: llamaindex
Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
Provides-Extra: mcp
Requires-Dist: mcp>=2.0.0; extra == "mcp"
Provides-Extra: litellm
Requires-Dist: litellm<1.97.0,>=1.90.0; extra == "litellm"
Provides-Extra: proxy
Requires-Dist: fastapi>=0.110.0; extra == "proxy"
Requires-Dist: uvicorn>=0.27.0; extra == "proxy"
Requires-Dist: httpx>=0.27.0; extra == "proxy"
Provides-Extra: all
Requires-Dist: chromadb>=0.4.0; extra == "all"
Requires-Dist: qdrant-client>=1.10.0; extra == "all"
Requires-Dist: faiss-cpu>=1.7.4; extra == "all"
Requires-Dist: redis>=5.0.0; extra == "all"
Requires-Dist: redisvl>=0.1.0; extra == "all"
Requires-Dist: pymilvus>=2.4.10; extra == "all"
Requires-Dist: milvus-lite<3,>=2.4.4; sys_platform != "win32" and extra == "all"
Requires-Dist: setuptools<82; sys_platform != "win32" and extra == "all"
Requires-Dist: grpcio<1.80; (python_version < "3.10" and sys_platform != "win32") and extra == "all"
Requires-Dist: sentence-transformers>=2.2.0; extra == "all"
Requires-Dist: openai>=1.0.0; extra == "all"
Requires-Dist: httpx>=0.27.0; extra == "all"
Requires-Dist: langchain-core>=0.1.0; extra == "all"
Requires-Dist: llama-index-core>=0.10.0; extra == "all"
Requires-Dist: mcp>=2.0.0; python_version >= "3.10" and extra == "all"
Requires-Dist: litellm<1.97.0,>=1.90.0; python_version >= "3.10" and extra == "all"
Requires-Dist: fastapi>=0.110.0; python_version >= "3.10" and extra == "all"
Requires-Dist: uvicorn>=0.27.0; python_version >= "3.10" and extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: httpx>=0.27.0; extra == "dev"
Requires-Dist: pytest-asyncio==0.21.1; extra == "dev"
Dynamic: license-file

# Sulci Cache

**The AI native context-aware semantic caching for LLM apps — stop paying for the same answer twice**

[![Patent Pending](https://img.shields.io/badge/Patent-Pending-blue.svg)](https://sulci.io)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Tests](https://github.com/sulci-io/sulci-oss/actions/workflows/tests.yml/badge.svg)](https://github.com/sulci-io/sulci-oss/actions/workflows/tests.yml)
[![PyPI](https://img.shields.io/pypi/v/sulci)](https://pypi.org/project/sulci/)
[![Python](https://img.shields.io/pypi/pyversions/sulci)](https://pypi.org/project/sulci/)
[![Downloads](https://pepy.tech/badge/sulci/month)](https://pepy.tech/project/sulci)
[![Downloads](https://pepy.tech/badge/sulci)](https://pepy.tech/project/sulci)

Sulci Cache is a drop-in Python library that caches LLM responses by **semantic meaning**, not exact string match. When a user asks _"How do I deploy to AWS?"_ and someone else later asks _"What's the process for deploying on AWS?"_, Sulci Cache returns the cached answer instead of calling the LLM again — saving cost and latency.

---

## Why Sulci Cache

| Without Sulci Cache          | With Sulci Cache                                         |
| ---------------------------- | -------------------------------------------------------- |
| Every query hits the LLM API | Semantically similar queries return instantly from cache |
| $0.005 per call, every time  | Cache hits need a local embedding, not an LLM call       |
| 1–3 second response time     | Cache hits: ~87 ms p50 on the benchmark machine (below)  |
| No memory across sessions    | Context-aware lookups: recent conversation turns are blended into the lookup |

**Benchmark results — measured on the shipped MiniLM embedder**, 5,000-query
corpus, `threshold=0.85`. Ranges are across four seeded draws
(`--seed 1 2 3 42`):

| | measured | range | n |
|---|---|---|---|
| Repeat questions served from cache | **99.9%** | `[99.85–99.95%]` | 4,000 |
| Correctly declined when it has no answer | **98.9%** | `[98.15–99.38%]` | 2,588 |
| Of all cache hits, share that are correct | **93.8%** | `[92.13–94.69%]` | 4,014 |
| Cache hit, p50 | **87.19ms** | `[86.96–87.73ms]` | 4,014 |

Reproduce:

From a clone of this repository (the benchmark is not in the PyPI package):

```bash
git clone https://github.com/sulci-io/sulci-oss.git
cd sulci-oss
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[sqlite]"
python3 benchmark/run.py --use-sulci --fresh --no-sweep --seed 1 --out /tmp/sulci-bench-minilm
```

Give every `--use-sulci` run its own `--out`: the default, `benchmark/results/minilm/`, holds the four published
draws, and `run.py` refuses to write loose output beside them.

⚠️ **87.19ms is environment-dependent** — it is a MiniLM forward pass on the
benchmark machine, not a property of the cache. Measure it on your own hardware
before planning against it.

**Context-aware mode** blends prior conversation turns into the lookup vector, so
a vague follow-up such as *"what about the second one?"* is moved toward stored
questions that resemble the conversation and can find a fully specified answer
already in the cache. It does **not** keep apart answers stored under the same
words: entries are stored under their own query's embedding, with no context, so
an answer stored as *"how do I fix it?"* is a candidate for every conversation
that asks it. Store answers under a self-contained version of the question. How
much blending helps depends entirely on your query mix, and **this README does not carry
a number for it.** Run `python3 benchmark/run.py --use-sulci --fresh --no-sweep
--context --out /tmp/sulci-bench-context` and read the harness's own note: the context corpus is 125 follow-ups
across 25 sessions, which is small.

> Figures previously in this block — `85.9%`, `0.74ms`, `$21.47`, and a
> `56.8% → 77.6% (+20.8pp)` context claim on an "800-pair" corpus — were
> **retired 2026-08-12** and replaced above. They were measured on the built-in
> TF-IDF engine, which ships in no product, and the corpus was 125 follow-ups
> rather than 800. `pyproject.toml` makes this file the PyPI project page, so
> they were the most widely-read copy in the estate. See
> `sulci-platform docs/marketing/CLAIMS.md` for the register.

**Agent workloads** are the headline result, measured on real MiniLM across
four corpus draws (`--seed 1 2 3 42`, n=10,000):

| | measured |
|---|---|
| aggregate per-session hit rate | **95.01%** `[94.99–95.05]` |
| cold session | **33.4%** `[30.0–35.0]`, n=200 |
| warm session | **99.74%** `[99.69–99.81]`, n=200 |

Reproduce, from a clone set up as above, with
`python3 benchmark/run.py --agent --use-sulci --fresh --seed 1 --out /tmp/sulci-bench-agent`.

> The **~60–75%** figure this paragraph used to carry was never measured. It
> came from one line in `benchmark/README.md` that asserted it and called it
> "the number to cite externally." Retired 2026-08-04; `CLAIMS.md` recorded
> the correction as landed, and it had not landed here. Fixed 2026-08-11.

---

## Install

**Step 1 — Install Sulci Cache with a backend.** Start with SQLite (zero infrastructure):

```bash
pip install "sulci[sqlite]"
```

| Backend | Install |
|---|---|
| SQLite — zero infra, local dev | `pip install "sulci[sqlite]"` |
| ChromaDB | `pip install "sulci[chroma]"` |
| FAISS | `pip install "sulci[faiss]"` |
| Qdrant | `pip install "sulci[qdrant]"` |
| Redis + RedisVL | `pip install "sulci[redis]"` |
| Milvus Lite (Linux and macOS; on Windows the extra installs pymilvus only — use `MilvusBackend(uri=...)`) | `pip install "sulci[milvus]"` |
| Sulci Cloud managed backend — no extra needed (httpx is included) | `pip install sulci` |

**LangChain integration:**

```bash
pip install "sulci[sqlite,langchain]"
```

**LlamaIndex integration** (Python 3.10+):

```bash
pip install "sulci[sqlite,llamaindex]"
```

**MCP server, caching proxy, LiteLLM** (Python 3.10+):

| Surface | Install |
|---|---|
| MCP server (`sulci-mcp`) | `pip install "sulci[sqlite,mcp]"` |
| OpenAI/Anthropic caching proxy (`sulci-proxy`) | `pip install "sulci[sqlite,proxy]"` |
| LiteLLM cache layer | `pip install "sulci[sqlite,litellm]"` |

**AsyncCache (non-blocking async wrapper):**

AsyncCache is included — no extra install needed:

```bash
pip install "sulci[sqlite]"
```

**Step 2 — Install your LLM SDK** (required for `cached_call` with a live model):

For Anthropic / Claude:

```bash
pip install anthropic
```

For OpenAI:

```bash
pip install openai
```

> **zsh users:** always wrap extras in quotes — `"sulci[sqlite]"` not `sulci[sqlite]`.

> **Cloud backend, since v0.6.3:** `pip install sulci` (no extras) already pulls `httpx` as a mandatory dependency, so the `[cloud]` extra is now a back-compat no-op. New installs can do `pip install sulci` and use `Cache(backend="sulci", api_key="sk-sulci-...")` directly. The `[cloud]` extra is kept for users who pinned the extras-bearing install command in their pyproject.toml or requirements.txt — installing it does no harm, just no longer required.

---

## LangChain Integration

The LangChain adapter is a stateless semantic cache. LangChain's cache interface
carries no conversation id, so `SulciCache` calls `get(prompt)` / `set(prompt, …)`
without a `session_id` (`sulci/integrations/langchain.py`) and `context_window`
has no effect there. 📌 Since 0.10.1, passing a non-zero `context_window` to the stock adapter logs a warning (a subclass that passes its own `session_id` still uses it). For context-aware lookup, use the LlamaIndex adapter with a
per-call `session_id=`, or call `Cache.cached_call(..., session_id=...)` directly
(see *Context-Aware Blending* below for what blending does and does not do).

```python
from langchain_core.globals import set_llm_cache
from sulci.integrations.langchain import SulciCache

# Stateless semantic — drop-in for GPTCache
set_llm_cache(SulciCache(backend="sqlite"))

# Managed Sulci Cloud — reads your key from the SULCI_API_KEY environment variable
# set_llm_cache(SulciCache(backend="sulci"))
```

Install: `pip install "sulci[sqlite,langchain]"`

---

## LlamaIndex Integration

`SulciCacheLLM` is a native LLM-level semantic cache for LlamaIndex with
no LangChain dependency required. It wraps any LlamaIndex-compatible LLM (`OpenAI`, `Anthropic`, `Ollama`, `HuggingFaceLLM`, etc.) — `complete()` and `chat()` are cached, streaming passes through uncached, async methods use `run_in_executor`.

```python
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from sulci.integrations.llamaindex import SulciCacheLLM

# Stateless — drop-in for any LlamaIndex LLM
Settings.llm = SulciCacheLLM(
    llm       = OpenAI(model="gpt-4o"),
    backend   = "sqlite",
    threshold = 0.85,
)

# Context-aware — RAG chatbot / agent: pass session_id on each call
Settings.llm = SulciCacheLLM(
    llm            = OpenAI(model="gpt-4o"),
    backend        = "sqlite",
    threshold      = 0.75,
    context_window = 4,
)
Settings.llm.complete("How do I fix it?", session_id="conv-42")

# Managed Sulci Cloud — reads your key from the SULCI_API_KEY environment variable
# Settings.llm = SulciCacheLLM(llm=OpenAI(model="gpt-4o"), backend="sulci")
```

Install (Python 3.10+), and set your OpenAI key (the example's `OpenAI` LLM class is a separate LlamaIndex package):

```bash
pip install "sulci[sqlite,llamaindex]" llama-index-llms-openai
export OPENAI_API_KEY=sk-your-openai-key
```

**Alternative — route LlamaIndex through the LangChain adapter:**

```python
from langchain_core.globals import set_llm_cache
from sulci.integrations.langchain import SulciCache
from llama_index.llms.langchain import LangChainLLM
from langchain_openai import ChatOpenAI

set_llm_cache(SulciCache(backend="sqlite"))   # stateless: no session id on this path

from llama_index.core import Settings
Settings.llm = LangChainLLM(llm=ChatOpenAI(model="gpt-4o"))
```

Install: `pip install "sulci[sqlite,langchain]" llama-index-llms-langchain langchain-openai`


## MCP Server

Agents that run in containers — Copilot CLI, Claude Code, Codex, GitHub
Agentic Workflows — cannot `pip install sulci` into their own process. They
can call an MCP server.

```bash
pip install "sulci[sqlite,mcp]"
sulci-mcp --backend sqlite --db-path ./sulci_db
```

Tools: `cache_lookup` and `cache_stats` (both annotated `readOnlyHint`), and
`cache_store` (annotated as a write). `SULCI_MCP_READ_ONLY=1` registers only
the read-only pair. Transports: `stdio` (default), `sse`, `streamable-http`.

Requires **mcp >= 2.0.0**. mcp 1.x is not supported — the `FastMCP` entry
point it used was removed in 2.0.

See `examples/gh_aw_sulci_mcp.md` for a GitHub Agentic Workflow that mounts
the store on gh-aw's `cache-memory` so it survives between runs.


## Caching Proxy (OpenAI / Anthropic compatible)

The zero-code-change door. Point any SDK or CLI at it and every call is
cached — no import, no adapter, no cooperation from the caller.

In one terminal (the proxy stays in the foreground):

```bash
pip install "sulci[sqlite,proxy]"
sulci-proxy --backend sqlite --db-path ./sulci_proxy_db --port 8787
```

In the terminal where your app or CLI runs:

```bash
export OPENAI_BASE_URL=http://localhost:8787/v1
export ANTHROPIC_BASE_URL=http://localhost:8787
```

Serves `POST /v1/chat/completions` and `POST /v1/messages`, plus `/healthz`
and `/stats`. Every response carries `x-sulci-cache: hit | miss |
miss-uncacheable | bypass`; hits also carry `x-sulci-similarity`.

Scope a lookup with the `x-sulci-tenant-id` and `x-sulci-session-id` headers.

⚠️ **A cache hit is not authenticated.** Your `Authorization` header is
forwarded upstream only on a **miss**; a hit is served from the store without
any credential check, so an invalid or expired key still returns 200 for a
question already cached. This is inherent to every caching proxy, and it is
why `--host` defaults to `127.0.0.1`. Read access to the proxy is read access
to the cache contents — put your own auth in front of it before binding it to
anything but localhost.

⚠️ **Give it a dedicated `--db-path`.** `./sulci_db` is sulci's *default*
path, shared with anything else that omits `db_path`. Pointing the proxy at it
means unrelated entries can satisfy a lookup.

**Not cached, by design:** streaming requests (`"stream": true`) pass through
untouched; tool-call responses are never stored, because their arguments are
state-dependent and a stale one sends an agent to the wrong file. Cached
replies report `usage.total_tokens == 0` — no tokens were consumed upstream,
and fabricating the original counts would corrupt billing reconciliation.


## LiteLLM

Sulci as the cache layer inside a LiteLLM deployment.

```bash
pip install "sulci[sqlite,litellm]"
export OPENAI_API_KEY=sk-your-openai-key
```

```python
from sulci.integrations.litellm import install
install(backend="sqlite", context_window=4)

import litellm
reply = litellm.completion(
    model="gpt-4o",
    messages=[{"role": "user", "content": "How do I reset my password?"}],
)   # now cached: the same question again is served from Sulci
print(reply.choices[0].message.content)
```

LiteLLM has no `custom` cache type — `install()` replaces the inner
implementation on `litellm.cache`, which is how LiteLLM's own semantic
caches are wired. Pass the conversation id as
`metadata={"sulci_session_id": ...}` to get context blending.


## ⚠️ The MCP, proxy, LiteLLM and LlamaIndex extras need Python 3.10+

Sulci itself supports Python 3.9. `mcp`, `litellm` and `fastapi` all declare
`requires-python >=3.10`, so `pip install "sulci[mcp]"` (or `[proxy]`,
`[litellm]`) will not resolve on 3.9. The core library, the backends and the
LangChain adapter are unaffected. `sulci[llamaindex]` installs on 3.9 but its
adapter raises `ImportError` there: the `llama-index-core` pip picks on 3.9
cannot import.


## Tenant and user isolation

Since **0.9.3, every backend enforces `tenant_id` and `user_id`** on every lookup:

| backend | isolation |
|---|---|
| chroma, faiss, milvus, qdrant, redis, sqlite | **enforced** — `search()` reads only the caller's own tenant and user, inside the search itself; each declares `ENFORCES_TENANT_ISOLATION = True` |
| sulci (cloud) | **enforced server-side** by the Sulci Cloud gateway, keyed off the API key (one key per tenant). Its `ENFORCES_TENANT_ISOLATION` flag reads `False` only because the OSS test suite cannot reach the gateway to verify it. |

`None` means the shared scope and nothing else: an entry stored without a
tenant is not visible to a lookup with one, and the reverse. The three surfaces
above — `sulci-mcp --tenant-id`, `namespace_by_model=True`, the proxy's
`x-sulci-tenant-id` and per-model scoping — therefore scope for real on every
backend.

**`user_id` is a separate scope.** It takes effect only with `personalized=True`
— with the default `personalized=False`, `Cache` ignores it. With
`personalized=True`, pass `user_id` on both `set` and `get`: a lookup with a
`user_id` matches only that user's entries, and a lookup without one matches
only entries stored without one.

**Refused ids.** `Cache.get` / `set` (and so `cached_call`, `AsyncCache` and the
integrations) raise `ValueError` for a `tenant_id` — or, with
`personalized=True`, a `user_id` — that is `"global"` or any falsy value other
than `None` (`""`, `0`, `False`), and for an id that is not valid Unicode text.
`"global"` is the sentinel the shared scope is stored under. Map empty ids to
`None` before calling `Cache`.

**Upgrading from 0.9.2 or earlier:** on Redis, FAISS and Milvus an entry stored
before 0.9.3 carries no tenant and is never matched again; on Chroma and Redis
an old entry keeps its old id, so storing the same question again does not
replace it. `clear()` a Chroma, Redis, FAISS or Milvus store once after
upgrading. Versions
before 0.9.3 did not isolate tenants on any backend but Qdrant, and before 0.9.2
SQLite and Milvus did not reliably keep users apart; see the
[CHANGELOG](CHANGELOG.md) *Security* entries for 0.9.2 and 0.9.3.

**Upgrading from 0.9.3 or earlier:** on Milvus, an entry stored before 0.10.0 is never returned (lookups filter on
a scope key older entries lack); `clear()` a Milvus store once after upgrading. On FAISS, similarity is cosine
since 0.10.1, as on every other backend; if you lowered a FAISS threshold to compensate on 0.10.0 or earlier,
raise it back. See the [CHANGELOG](CHANGELOG.md) entries for 0.10.0 and 0.10.1.

---

## AsyncCache — non-blocking async wrapper

`AsyncCache` wraps `sulci.Cache` with `asyncio.to_thread()` so every cache
operation yields the event loop. The correct pattern for FastAPI, LangChain
async chains, LlamaIndex async agents, and any asyncio-based application.

Save as `app.py`:

```python
from fastapi import FastAPI
from sulci import AsyncCache

app = FastAPI()
cache = AsyncCache(backend="sqlite", context_window=4)

async def call_llm(query: str) -> str:
    # Replace with your async LLM client call.
    return f"(LLM answer to: {query})"

# FastAPI endpoint — event loop never blocked
@app.post("/chat")
async def chat(query: str, session_id: str):
    response, sim, depth = await cache.aget(query, session_id=session_id)
    if response:
        return {"response": response, "source": "cache", "sim": sim}
    response = await call_llm(query)
    await cache.aset(query, response, session_id=session_id)
    return {"response": response, "source": "llm"}
```

Run it, then ask the same question twice — the second answer comes from the cache:

```bash
pip install "sulci[sqlite]" fastapi uvicorn
uvicorn app:app --port 8000
```

```bash
curl -s -X POST "http://localhost:8000/chat?query=How+do+I+reset+my+password&session_id=s1"
curl -s -X POST "http://localhost:8000/chat?query=How+do+I+reset+my+password&session_id=s1"
```

All `Cache` parameters work identically:

```python
from sulci import AsyncCache

cache = AsyncCache(
    backend        = "sqlite",
    threshold      = 0.85,
    context_window = 4,
    query_weight   = 0.70,
)
```

**Async methods:** `aget()`, `aset()`, `acached_call()`, `aget_context()`,
`aclear_context()`, `acontext_summary()`, `astats()`, `aclear()`, `adelete_user()` (0.10.0)

**Sync passthrough:** All sync methods (`get`, `set`, `cached_call`, `stats`, `clear`, `delete_user`,
`get_context`, `clear_context`, `context_summary`) also
available — `AsyncCache` works in mixed sync/async codebases without switching types.

**Partition & per-call kwargs:** every method — async **and** sync passthrough —
forwards the same set of kwargs `Cache` accepts. Keyword-only, all default `None`: `tenant_id`
(multi-tenant isolation) and `plan` (plan tier on the emitted `CacheEvent`) on
every method, `metadata` on `aset`/`set`, and the per-call `threshold` on
`aget`/`acached_call` and their passthroughs (`tenant_id`/`plan`/`metadata`
parity v0.8.1; passthrough `threshold` parity v0.8.2):

```python
import asyncio
from sulci import AsyncCache

cache = AsyncCache(backend="sqlite")

async def main():
    await cache.aset("What is the refund window?", "30 days.", tenant_id="acme", plan="pro", metadata={"src": "kb"})
    resp, sim, depth = await cache.aget("What is the refund window?", tenant_id="acme", plan="pro", threshold=0.9)
    print(resp, round(sim, 2), depth)

asyncio.run(main())
```

> **The mirror is of *which* kwargs are forwarded, not of *how* they are passed.**
> On `aget` and `acached_call`, `threshold` — along with `user_id`, `session_id`,
> `cost_per_call` — is positional-or-keyword, whereas `Cache.get` makes everything
> after `query` keyword-only. So `await cache.aget(q, uid, sid, 0.9)` is legal
> while `cache.get(q, uid, sid, 0.9)` is a `TypeError`. Not a bug, but if you read
> "100% mirror" as full signature parity you will be surprised.
> `set` deliberately has no `threshold`, because `Cache.set` has none — the mirror
> is faithful, not a superset. Full measured surface:
> [`docs/API-SURFACE.md`](docs/API-SURFACE.md).

---
## Sulci Cloud — zero infrastructure option

Get a free API key at **[sulci.io/signup](https://sulci.io/signup)** and switch
to the managed backend with a single parameter change. Everything else stays identical.

```python
from sulci import Cache

# Before — self-hosted
cache = Cache(backend="sqlite", threshold=0.85)

# After — managed cloud (zero other code changes)
cache = Cache(backend="sulci", api_key="sk-sulci-your-key")

# Or with the key in the SULCI_API_KEY environment variable
cache = Cache(backend="sulci")
```

On `backend="sulci"`, leave `threshold` out to use the threshold saved in your dashboard (0.10.1). A value
passed to the constructor or per call, 0.85 included, is sent with every lookup and overrides it.

On `backend="sulci"`, `context_window` works the same way (0.10.2): a window you pass, `Cache(backend="sulci",
context_window=2)`, is sent with every lookup; leave it out to use the Context window saved in your dashboard. The
managed backend also treats a set in a session that already holds a turn as a follow-up: it records the turn but
stores no entry, because words like "how do I fix it?" mean something only in their conversation. Pass
`standalone=True` when the query stands on its own — a self-contained rewrite of the follow-up, or an agent prompt
that carries its own state — and it is stored:

```python
cache = Cache(backend="sulci", context_window=2)

# a follow-up, stored under a self-contained rewrite
cache.set("How do I fix a Docker container that exits right after it starts?", answer,
          session_id="s1", standalone=True)

# an agent step whose prompt carries the task's state
cache.cached_call(prompt, llm_fn, session_id="task-42", standalone=True, threshold=0.97)
```

Before 0.10.2 the SDK sent neither, so a Python app on the managed backend blended no context unless the dashboard
was set, and never stored a follow-up.

See [sulci.io](https://sulci.io) for current plans. The free OSS-Connect key is
open now; paid plans (Pro, Business) open for self-serve signup at general
availability — until then, request access from the pricing page.

### One-line setup — telemetry just works (v0.7.0+)

As of v0.7.0, passing `api_key` to `Cache()` enables the Sulci Cloud
dashboard automatically. **One line is enough** — no separate `sulci.connect()`
call required for the dashboard panels (TrendChart, AuditEventsTable,
DeploymentsTable, Active SDKs) to populate.

```python
from sulci import Cache

cache = Cache(
    backend = "sulci",                # or "sqlite"/"chroma"/etc. — see below
    api_key = "sk-sulci-...",         # or set SULCI_API_KEY env var
)

cache.get("hello")                    # populates the entire dashboard
```

This works for every tier and every backend choice:

| Persona | Backend | api_key role |
|---|---|---|
| **Pro / Business (paid managed)** | `"sulci"` | cache auth + telemetry |
| **OSS-Connect (free, self-host + dashboard)** | `"sqlite"` / `"chroma"` / `"qdrant"` / etc. | **telemetry only** — cache lives locally. (The same key also reaches `backend="sulci"`, capped at 10,000 managed requests a month.) |
| **Pure self-hosted (no Sulci account)** | local backend | omit `api_key` — no telemetry, no cloud |

**One rule covers all three:**

> If `api_key` is present anywhere (kwarg, `SULCI_API_KEY` env, or prior
> `sulci.connect()`) AND `telemetry=True` (the default), telemetry flows
> to Sulci. Backend choice is independent.

**Telemetry remains strictly opt-in.** No api_key anywhere → no telemetry,
ever. Pass `telemetry=False` to override: `Cache(backend="sulci",
api_key="sk-sulci-...", telemetry=False)` uses the managed cache without
emitting telemetry (useful in compliance-restricted environments or
internal staging where dev traffic should not pollute production
dashboards).

### sulci.connect() — advanced flows

`sulci.connect()` is still the canonical entry point for two patterns that
benefit from explicit ordering:

**1. OSS-Connect device-code onboarding (browser-based auth)**

```python
import sulci
from sulci import Cache

sulci.connect(prompt=True)            # opens browser, registers key in ~/.sulci/config
cache = Cache(backend="sqlite")       # cache lives locally; telemetry already wired
```

**2. Register key at boot, construct Cache later (multi-worker apps)**

```python
# At app startup, before workers spawn:
import sulci
sulci.connect(api_key="sk-sulci-...")

# Later, in a worker thread / lazy init:
from sulci import Cache
cache = Cache(backend="sulci")        # picks up the already-registered key
```

Both flows short-circuit the v0.7.0 auto-connect logic by setting
`sulci._telemetry_enabled` to its intended state before `Cache()` runs.
The auto-connect block respects that and does nothing — your explicit
`connect()` choice (including `telemetry=False` if you passed it) survives.

**Key resolution order** (first match wins):

```
1. Explicit api_key= argument to sulci.connect() or Cache()
2. SULCI_API_KEY environment variable
3. ~/.sulci/config (persisted by a prior successful sulci.connect() call)
4. Browser-based OSS-Connect device-code flow — only if prompt=True
```

All four are equivalent opt-in signals per the §5.2 trust-boundary spec.
Pre-v0.7.0 only paths (1) via `connect()`, (2), and (3) flipped the
telemetry flag; v0.7.0 makes path (1) via `Cache(api_key=...)` equivalent
to the others, which is what eliminates the historic footgun.

Step 3 (config persistence) ships in **v0.5.3**. After your first successful
`sulci.connect(api_key="sk-sulci-...")`, the key is persisted to
`~/.sulci/config` (mode 0600) and subsequent `sulci.connect()` calls with
no arguments will pick it up automatically.

Step 4 (device-code flow) shipped **latent** in v0.5.3 — the SDK code was in
place but the gateway endpoints and dashboard page had not deployed. **That
is no longer the case: the full chain (SDK + gateway `/v1/oss-connect/*` +
dashboard `/oss-connect`) has been live end-to-end since 2026-05-08.**

`prompt` nonetheless still defaults to `False`, and as of 2026-07-06 that is
a sustained decision rather than a stale promise — see
`sulci.connect`'s docstring (`sulci/__init__.py`) for the three reasons.
Step 4 is opt-in per call:

```python
import sulci

# v0.5.3+ default — safe everywhere:
sulci.connect()
# - Steps 1-3 work normally
# - Step 4 is skipped (prompt=False default)
# - If no key found, connect() returns silently (no telemetry enabled)

# Opt in to the browser flow (live since 2026-05-08):
sulci.connect(prompt=True)
# - First-run: prints "Visit https://dashboard.sulci.io/oss-connect and enter code: WXYZ-2345"
# - User authorizes via browser → SDK gets api_key and persists to ~/.sulci/config
# - Subsequent runs: step 3 short-circuits, no browser needed
```

**`prompt=False` is the permanent default.** v0.6.0 was once pencilled in to
flip it; v0.6.0 shipped (2026-05-11) focused on the cloud transport rewrite
instead, and the flip was subsequently decided against outright. The reasoning
is recorded in `sulci.connect`'s docstring and dated 2026-07-06:

1. A non-interactive default is safe for library callers with no tty or
   browser — LangChain / LlamaIndex agents, FastAPI handlers, LangGraph
   nodes, CI runners. `prompt=True` at import time would block those on a
   15-minute device-code timeout with no visible cause.
2. v0.7.0's `Cache()` auto-connect already covers the ergonomic path:
   passing `api_key=` to the constructor attaches telemetry, with no
   blocking browser prompt as an import-time side effect.
3. Explicit `prompt=True` remains first-class — one keyword argument, opt
   in per call on an interactive machine.

Treat this as settled. Do not re-file it as pending work.

---

## Quickstart

### Stateless (v0.1 style)

```python
from sulci import Cache

cache = Cache(backend="sqlite", threshold=0.85)

# store a response
cache.set("How do I deploy to AWS?", "Use the AWS CLI with 'aws deploy'...")

# exact or semantic hit — returns 3-tuple
response, similarity, context_depth = cache.get("What's the process for deploying on AWS?")

if response:
    print(f"Cache hit (sim={similarity:.2f}): {response}")
else:
    # call your LLM here
    pass
```

### Context-aware (v0.2 style)

```python
from sulci import Cache

cache = Cache(
    backend        = "sqlite",
    threshold      = 0.85,
    context_window = 4,     # remember last 4 turns
    query_weight   = 0.70,  # α — weight of current query vs context
    context_decay  = 0.50,  # halve weight per older turn
)

# turn 1
cache.set("What is Python?", "Python is a high-level programming language.", session_id="s1")

# turn 2 — context from turn 1 blended into the lookup vector
response, sim, depth = cache.get("Tell me more about it", session_id="s1")
```

A blended vector scores lower against stored entries than an exact-match lookup,
so with `context_threshold` unset, context lookups are compared against
`threshold` and Sulci emits a `UserWarning` saying so. Set `context_threshold=`
to a value calibrated on your own follow-up queries; sulci ships no default
because no measurement supports one. See
[`docs/context-threshold.md`](docs/context-threshold.md).

### Drop-in with `cached_call`

> **Requires:** `pip install "sulci[sqlite]" anthropic`
>
> ```bash
> export ANTHROPIC_API_KEY=sk-ant-...
> ```

```python
import anthropic
from sulci import Cache

cache = Cache(backend="sqlite", threshold=0.85)
client = anthropic.Anthropic()

def call_llm(prompt: str) -> str:
    msg = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    return msg.content[0].text

result = cache.cached_call(
    query  = "How do I deploy to AWS?",
    llm_fn = call_llm,
)

print(result["response"])
print(f"Source:  {result['source']}")       # "cache" or "llm"
print(f"Latency: {result['latency_ms']:.1f}ms")
```

Run it a second time with the same (or similar) question — `source` switches to `"cache"` and the LLM round-trip disappears: latency drops from the model's response time (typically seconds) to the local embedding lookup (~87 ms p50 on the benchmark machine; measure your own).

---

## API Reference

### Constructor

Every parameter, at its default (the default backend, `"chroma"`, needs `pip install "sulci[chroma]"`):

```python
from sulci import Cache

cache = Cache(
    backend         = "chroma",     # chroma | sqlite | faiss | qdrant | redis | milvus | sulci
    threshold       = None,         # cosine similarity cutoff (0–1); None = 0.85 on a local backend, your dashboard's setting on "sulci" (0.10.1)
    embedding_model = "minilm",     # minilm | mpnet | bge | openai
    ttl_seconds     = 86400,        # 24h. None = no expiry
    personalized    = False,        # scope lookups to the user_id passed on get/set
    db_path         = "./sulci_db", # on-disk path for sqlite / faiss
    context_window  = 0,            # turns to remember; 0 = stateless
                                    # 0.10.2: the default is None (reads as 0); on "sulci", your dashboard's setting
    query_weight    = 0.70,         # α in blending formula
    context_decay   = 0.50,         # per-turn decay weight
    session_ttl     = 3600,         # session expiry in seconds
    telemetry       = True,         # set False to disable per-instance
    api_key         = None,         # required when backend="sulci"; also SULCI_API_KEY
    gateway_url     = "",           # cloud gateway override; also SULCI_GATEWAY (v0.7.4)
    session_store   = None,         # custom SessionStore implementation
    event_sink      = None,         # custom EventSink implementation
    cost_per_call   = 0.005,        # $ per LLM call, for saved-cost stats
    context_threshold = None,       # threshold for context-blended lookups; None = use threshold (warns)
)
```

📌 0.10.2: on `backend="sulci"`, writing `context_window = 0` as above now sends 0 (stateless) and overrides your
dashboard's Context window; leave the parameter out to use the dashboard's setting.

### Methods

| Method                                                                                 | Returns                   | Description                                                        |
| -------------------------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------ |
| `cache.get(query, *, threshold=None, tenant_id=None, user_id=None, session_id=None, plan=None)`                   | `(str\|None, float, int)` | response, similarity, context_depth (tenant_id added in v0.4.0; plan added in v0.5.6) |
| `cache.set(query, response, *, tenant_id=None, user_id=None, session_id=None, metadata=None, plan=None)` | `None`                    | Store entry, advance context window (plan added in v0.5.6)                            |
| `cache.cached_call(query, llm_fn, *, threshold=None, tenant_id=None, user_id=None, session_id=None, cost_per_call=None, plan=None)` | `dict`        | response, source, similarity, latency_ms, cache_hit, context_depth (plan added in v0.5.6) |
| `cache.get_context(session_id, *, tenant_id=None, user_id=None)`                       | `ContextWindow`           | Return session's context window                                    |
| `cache.clear_context(session_id, *, tenant_id=None, user_id=None)`                     | `None`                    | Reset session history                                              |
| `cache.context_summary(session_id=None, *, tenant_id=None, user_id=None)`              | `dict`                    | Snapshot of one or all sessions                                    |
| `cache.stats()`                                                                        | `dict`                    | hits, misses, hit_rate, saved_cost, total_queries, active_sessions |
| `cache.clear()`                                                                        | `None`                    | Evict all entries, reset stats and sessions                        |
| `cache.delete_user(user_id, *, tenant_id=None)` (0.10.0)                               | `{"entries", "sessions"}` | Erase one user of one tenant: entries + sessions                   |

> **Important:** `cache.get()` returns a **3-tuple** `(response, similarity, context_depth)` — not a 2-tuple like v0.1. Always unpack all three values.

> **Sessions are scoped (0.9.3).** A session belongs to one tenant and, with `personalized=True`, one user — the
> same `session_id` from another tenant or user is another conversation. Pass `get_context`, `clear_context` and
> `context_summary` the same `tenant_id=` / `user_id=` you pass to `get` / `set`. `context_summary()` with a
> `tenant_id` or `user_id` but no `session_id` raises `TypeError`.

**That is the whole public surface — nine methods.** The constructor defaults and method signatures above match an
AST measurement of `sulci/core.py` at 0.10.1 (`python3 scripts/check_api_surface.py --show`), not memory. Re-run it
before trusting any restatement of this table found elsewhere; the full measured surface is in
[`docs/API-SURFACE.md`](docs/API-SURFACE.md).

**Erasing one user — `cache.delete_user` (0.10.0).** `cache.delete_user("u-42", tenant_id="t1")` erases that
user's cache entries and conversation history in that tenant and returns `{"entries": N, "sessions": M}`. Every
shipped backend implements it (`Backend.delete_user` is part of the protocol); a third-party backend without it
raises `NotImplementedError`. It needs `personalized=True` — without it `user_id` is ignored and nothing is filed
per user, so it raises `ValueError` and `clear()` is the erasure route. On the managed backend it calls
`DELETE /v1/cache/user/{id}`, and `tenant_id` is refused (the API key decides the tenant). When it returns, no
lookup through Sulci can reach that user's data; the bytes may remain on disk until the store vacuums or compacts,
and copies outside Sulci (event sinks, logs, backups) are yours to erase. Use a stable, system-assigned user id:
Sulci cannot tell that an email address and an internal id are one person. Redis scans every entry key under its
prefix and FAISS rebuilds its index to erase, so on those two the cost grows with the size of the store (FAISS:
about 1 s at 10,000 entries).

```python
from sulci import Cache

cache = Cache(backend="sqlite", db_path="./sulci_erasure_demo", personalized=True)
cache.set("What is my plan?", "You are on Pro.", tenant_id="t1", user_id="u-42")
print(cache.delete_user("u-42", tenant_id="t1"))   # {'entries': 1, 'sessions': 0}
```

**Erasure raises when it is not complete.** A failed `delete_user` raises `sulci.errors.SulciErasureError`
(also importable from `sulci.backends.cloud`) saying what was erased; repeat the call to complete it. On the managed
backend `cache.clear()` likewise raises it unless the gateway confirms the erasure — a transport error, any non-2xx
answer such as `503 erasure_incomplete`, or an answer that is not the expected JSON — before resetting stats or
sessions. Catch it where a failed erasure must not stop the caller. The user id is percent-encoded into one path
segment, so any id is addressed exactly.

### Advanced constructor options

**Custom session store and event sink.** `session_store=` accepts any
`sulci.sessions.SessionStore` implementation (default `None` = the in-process
manager); `event_sink=` accepts any `sulci.sinks.EventSink` (default `None` = a
no-op `NullSink`). The shipped sinks enforce a strict field allowlist — query
text, response text and embeddings never leave the process.

📌 0.9.3: a session store also implements `clear_scope(scope)` and `clear_all()` (the `Cache` refuses one that
does not, since `clear()` could not reach its history), and is handed tenant- and user-scoped session ids
(`sulci.sessions.scoped_session_id`); the shipped stores no longer take a `tenant_id`.

Needs `pip install "sulci[sqlite,redis]"`, a Redis server on `localhost:6379`, and an HTTPS endpoint of your own
that accepts the events (`TelemetrySink` refuses a plain `http://` URL):

```python
import redis
from sulci import Cache, RedisSessionStore, TelemetrySink

redis_client = redis.Redis.from_url("redis://localhost:6379/0")

cache = Cache(
    backend        = "sqlite",
    context_window = 4,
    session_store  = RedisSessionStore(redis_client),                        # horizontal-scale sessions
    event_sink     = TelemetrySink("https://telemetry.example.com/events"),  # privacy-firewalled events
)
```

When the caller knows a tenant's plan tier, pass `plan=` to `get` / `set` /
`cached_call`; it is attributed onto the emitted `CacheEvent`.

**Instance injection.** `embedding_model=` and `backend=` accept a
pre-constructed instance as well as a string — useful for connection pooling or
custom client configuration:

Needs `pip install "sulci[qdrant,openai]"` and `OPENAI_API_KEY`. A backend you construct yourself is created at
the dimension you give it (`QdrantBackend` defaults to 384, MiniLM's), so pass the embedder's —
`text-embedding-3-small` produces 1536:

```python
from sulci import Cache
from sulci.embeddings.openai import OpenAIEmbedder
from sulci.backends.qdrant import QdrantBackend

cache = Cache(
    embedding_model = OpenAIEmbedder(model="text-embedding-3-small"),
    backend         = QdrantBackend(db_path="./sulci_qdrant_openai", dimension=1536),
)
```

For a Qdrant server, pass `url=` (and `api_key=`) instead of `db_path=`.

**Managed cloud backend.** With `backend="sulci"` the query text is sent to the
gateway, which embeds and searches server-side, so no local embedding model is
loaded and `pip install sulci` alone is enough. The gateway URL resolves as
`gateway_url=` → `SULCI_GATEWAY` → `https://api.sulci.io`.

`SyncCache` is exported as an alias of `Cache`, parallel to `AsyncCache`.

### Telemetry and privacy

Telemetry is opt-in (see *Sulci Cloud* above for the one rule). When enabled:

- **Eight fields, nothing else.** The wire payload is limited to `event`,
  `backend`, `hits`, `misses`, `avg_latency_ms`, `sdk_version`,
  `python_version` and `fingerprint`. No query or response text, no
  embeddings. The gateway rejects extra fields server-side.
- **Per-deployment fingerprint** — a hash of a random `machine_id` plus
  backend, embedding model, threshold and context window. The `machine_id` is a
  `uuid4` generated once and stored in `~/.sulci/config` (mode 0600); no
  hostname, MAC or path.
- **Stale keys are refused.** A key persisted in `~/.sulci/config` more than
  90 days ago, or with no write timestamp, is ignored with a warning; pass
  `api_key=` once or re-run `sulci.connect(prompt=True)` to refresh it.
- **Passive nudge.** After 100 queries on an unconnected `Cache`,
  `cache.stats()` prints a one-line suggestion to connect, once per process.
  Silence it with `SULCI_QUIET=1`.
- **Redirecting the gateway.** `SULCI_GATEWAY` redirects telemetry, the
  device-code flow and the cloud backend together. It is read when `sulci` is
  imported, so set it in the environment first. Contributors: see
  [`LOCAL_SETUP.md`](./LOCAL_SETUP.md) Step 9 — never test against production.

### Release history

Every release, with its rationale and compatibility notes, is in
[`CHANGELOG.md`](./CHANGELOG.md). Earlier editions of this README carried
per-version "additions" sections; they stopped at v0.6.1 and have been folded
into the reference above and the changelog.

---

## Context-Aware Blending

When `context_window > 0`, Sulci Cache blends the current query vector with recent
conversation history before performing the similarity lookup:

```
lookup_vec = α · embed(query) + (1−α) · Σ(decay^i · turn_i)
```

- `α` = `query_weight` (default **0.70**) — how much the current query dominates
- `decay` = `context_decay` (default **0.50**) — halves weight per older turn
- Only **user query** vectors are stored in context (not LLM responses)
- Raw un-blended vectors stored in cache; blending happens at lookup time only —
  so an entry stored under a vague follow-up's words matches that wording in any
  conversation. Store answers under self-contained questions.

**Context-aware benchmark:** run it rather than reading a number here, and
run it on the shipped engine — `--use-sulci` is opt-in, and without it this
measures a built-in TF-IDF engine that is in no product.

From a clone set up as in the benchmark block at the top of this page:

```bash
python3 benchmark/run.py --use-sulci --fresh --no-sweep --context --out /tmp/sulci-bench-context
```

The corpus is **125 follow-ups across 25 sessions**, which is small — the
harness says so on every run. A per-domain gain table used to sit here
(+56pp customer support, +17.6pp overall) and was withdrawn 2026-08-11: it
was measured on the built-in TF-IDF engine rather than the shipped MiniLM
embedder, carried no n, described the corpus as 800 pairs when
`benchmark/run.py` says 125, and had stopped reproducing even on TF-IDF.

---

## Backends

| Backend         | ID       | Best for                                | Tenant isolation |
| --------------- | -------- | --------------------------------------- | ---------------- |
| SQLite          | `sqlite` | Local dev, edge, serverless, zero infra | yes (0.9.3) |
| ChromaDB        | `chroma` | Fastest path to working, Python-native  | yes (0.9.3) |
| FAISS           | `faiss`  | Local, file-backed (`faiss-cpu`); exact search within a scope since 0.9.3 | yes (0.9.3) |
| Qdrant          | `qdrant` | Production, metadata filtering          | yes |
| Redis + RedisVL | `redis`  | Existing Redis infra                    | yes (0.9.3) |
| Milvus Lite     | `milvus` | Dev-to-prod without code changes        | yes (0.9.3) |
| **Sulci Cloud** | `sulci`  | **Zero infra — managed service**        | yes (server-side) |

All self-hosted backends are free tier or self-hostable at zero cost. For
latency on your own hardware, run the benchmark (below); a hit is dominated by
the embedding step, not the backend search.

---

## Embedding Models

| ID       | Model                  | Dims | Notes                                        |
| -------- | ---------------------- | ---- | -------------------------------------------- |
| `minilm` | all-MiniLM-L6-v2       | 384  | **Default** — free, local; the benchmarked engine |
| `mpnet`  | all-mpnet-base-v2      | 768  | Local; slower, better quality                |
| `bge`    | BAAI/bge-base-en-v1.5  | 768  | Local; slower                                |
| `openai` | text-embedding-3-small | 1536 | Requires `OPENAI_API_KEY`                    |

The local models run via `sentence-transformers`. The first load downloads the
model from Hugging Face; later loads check it for updates unless
`HF_HUB_OFFLINE=1` is set. Beyond that, no network calls are made unless you
configure `embedding_model="openai"` or use the cloud backend.

---

## Project Structure

```
.
├── .envrc                      ← direnv: activates .venv (tracked in git)
├── .github/workflows/          ← CI: tests.yml (3 OS × Python 3.9–3.12), publish.yml, benchmark.yml
├── CHANGELOG.md  CONTRIBUTING.md  LICENSE  NOTICE  README.md  SECURITY.md
├── LOCAL_SETUP.md              ← contributor setup
├── Makefile                    ← smoke, test-*, checkin / checkin-fast, benchmark-verify, …
├── pyproject.toml              ← name="sulci", version, extras, console scripts
├── setup.py
├── setup.sh                    ← quick start only: installs a SUBSET of the Step 3 extras
│                                 and uses plain `python3` — follow Steps 2–3 for development
├── smoke_test.py  smoke_test_async.py  smoke_test_langchain.py  smoke_test_llamaindex.py
├── benchmark/
│   ├── README.md               ← methodology and results
│   ├── baseline.json           ← pinned TF-IDF regression baseline
│   ├── run.py                  ← benchmark CLI (Step 7)
│   └── results/                ← tfidf/ (scratch) and minilm/ (committed published draws)
├── docs/
│   ├── API-SURFACE.md          ← public API, checked by scripts/check_api_surface.py
│   ├── context-threshold.md    ← why context_threshold has no default
│   ├── protocols.md  multi_tenancy_and_isolation.md  OSS_BOUNDARY_POLICY.md
│   └── architecture/           ← ADRs (e.g. 0002 smoke-fast CPU mode)
├── examples/                   ← see Step 6 and API Key Notes
│   ├── basic_usage.py  context_aware.py  context_aware_example.py
│   ├── anthropic_example.py  langchain_example.py  llamaindex_example.py  async_example.py
│   ├── agent_example_langgraph.py  agent_example_crewai.py
│   ├── mcp_example.py  litellm_example.py  proxy_example.py  gh_aw_sulci_mcp.md
│   └── extending_sulci/        ← reference implementations of the protocols
├── scripts/                    ← see scripts/README.md
│   ├── run_tests_per_file.py  run_examples.py  verify_integration_examples.py
│   ├── verify_benchmark.py  check_agent_draws.py  check_api_surface.py
│   └── check_ci_test_coverage.py  check_release_ready.py  check_tag_version.py
├── sulci/
│   ├── __init__.py             ← exports Cache, AsyncCache, ContextWindow, SessionStore, connect()
│   ├── core.py                 ← Cache engine (context-aware)
│   ├── async_cache.py          ← AsyncCache wrapper
│   ├── context.py              ← ContextWindow + in-process session handling
│   ├── config.py               ← ~/.sulci/config persistence
│   ├── errors.py               ← SulciErasureError (0.10.0)
│   ├── telemetry.py  oss_connect.py   ← opt-in telemetry, device-code flow (Step 9)
│   ├── backends/               ← chroma, faiss, milvus, qdrant, redis, sqlite (free)
│   │                             + cloud (backend="sulci", managed); protocol.py
│   ├── embeddings/             ← minilm (default, local), openai; protocol.py
│   ├── integrations/           ← langchain, llamaindex, litellm, mcp_server
│   ├── proxy/                  ← sulci-proxy (OpenAI-compatible caching proxy)
│   ├── sessions/               ← SessionStore: memory, redis; protocol.py; errors.py (SulciSessionError, 0.10.0)
│   ├── sinks/                  ← EventSink: null, telemetry, redis_stream; protocol.py
│   └── tests/compat/           ← SessionStore + EventSink conformance suites
└── tests/                      ← test_*.py per feature, plus compat/ (backend + embedder
                                  conformance) and integration/flows/
```

---

## Running Tests

The full contributor setup — Python version, the exact extras, and the offline
switches that make the suite run in about a minute — is in
[`LOCAL_SETUP.md`](./LOCAL_SETUP.md). In short, from a clone:

```bash
python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[sqlite,redis,qdrant,langchain,llamaindex,mcp,litellm,proxy,dev]" pytest-timeout
python -m pytest tests/ -q -rs
```

After the first, online run (which downloads the embedding model), run offline:

```bash
export HF_HUB_OFFLINE=1 LITELLM_LOCAL_MODEL_COST_MAP=True
python -m pytest tests/ -q -rs
```

At v0.10.1 the suite collects 1,319 tests (`python -m pytest tests/ --collect-only -q`,
measured 2026-10-18 on Python 3.12, Linux; the pass/skip split is in `LOCAL_SETUP.md` Step 5). Skips are backends or servers you
don't have; failures or collection errors are not expected. Some missing extras
cause failures rather than skips, which is why the install line is long.

### Make targets

| Target | What it runs |
|---|---|
| `make smoke` | all four smoke tests (core, LangChain, LlamaIndex, AsyncCache) |
| `make smoke-fast` | same, forcing CPU inference (recommended on Apple Silicon) |
| `make test` | core pytest suite |
| `make test-integrations` | LangChain + LlamaIndex integration tests |
| `make test-async` | AsyncCache tests only |
| `make test-all` | full suite |
| `make test-cov` | full suite with coverage |
| `make checkin` | pre-PR check: smoke + per-file tests + examples + benchmark verify |
| `make checkin-fast` | same with CPU smoke mode — use this on macOS |

---

## Examples

From a clone of this repository, with the Step 3 install from [`LOCAL_SETUP.md`](./LOCAL_SETUP.md):

| Command | Needs |
|---|---|
| `python examples/basic_usage.py` | stateless cache — no API key |
| `python examples/context_aware.py` | context-aware — no API key |
| `python examples/context_aware_example.py` | more context-aware patterns — no API key |
| `python examples/anthropic_example.py` | `ANTHROPIC_API_KEY` |
| `python examples/langchain_example.py` | OpenAI or Anthropic key, or a mock fallback |
| `python examples/llamaindex_example.py` | OpenAI or Anthropic key, or a mock fallback |
| `python examples/async_example.py` | AsyncCache demo — OpenAI/Anthropic key, or a mock |
| `python examples/mcp_example.py` | MCP server — no API key |
| `python examples/litellm_example.py` | LiteLLM cache layer — OpenAI key, or a mock fallback |
| `python examples/agent_example_langgraph.py` | LangGraph agent demo — `langgraph`, `langchain-anthropic` |
| `python examples/agent_example_crewai.py` | CrewAI agent demo — `crewai` |
| `python examples/proxy_example.py` | a running `sulci-proxy` and `OPENAI_API_KEY` |

The agent examples need frameworks that aren't sulci extras; installing `crewai`
downgrades `mcp` below the 2.x the `mcp` extra needs, so re-run
`pip install "mcp>=2.0.0"` afterwards (see `LOCAL_SETUP.md` Step 8.5).

---

## Benchmark

From a clone of this repository (the benchmark is not in the PyPI package):

```bash
git clone https://github.com/sulci-io/sulci-oss.git
cd sulci-oss
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[sqlite]"
```

Fast run (~10 seconds) on the built-in TF-IDF engine — not the shipped engine, do not cite:

```bash
python3 benchmark/run.py --no-sweep --queries 1000 --out /tmp/sulci-bench-q1000
```

The same with the context-aware pass (TF-IDF, do not cite):

```bash
python3 benchmark/run.py --no-sweep --queries 1000 --context --out /tmp/sulci-bench-q1000-context
```

Full benchmark on the SHIPPED engine — the only form worth citing (~10 min):

```bash
python3 benchmark/run.py --use-sulci --fresh --no-sweep --context --out /tmp/sulci-bench-minilm-context
```

> **`--use-sulci` is opt-in.** Without it the benchmark runs a built-in TF-IDF
> engine that ships in no product; the shipped engine is `all-MiniLM-L6-v2`.
> The choice does not scale a number, it can invert a conclusion. Output is
> written to `benchmark/results/tfidf/` or `benchmark/results/minilm/`
> accordingly, so the two can never be confused for each other.

Give non-default runs their own `--out`: otherwise a TF-IDF run shares
`benchmark/results/tfidf/` with the regression check, which then refuses to
overwrite it, and a `--use-sulci` run refuses to start beside the published draws in
`benchmark/results/minilm/`.

See [`benchmark/README.md`](./benchmark/README.md) for full methodology and results.

---

## Troubleshooting

### `ImportError: cannot import name 'HfFolder' from 'huggingface_hub'`

Conda environments often have a stale `huggingface_hub` that conflicts with `sentence-transformers`. Fix by upgrading all three together:

```bash
pip install --upgrade huggingface_hub datasets sentence-transformers
```

Or use a clean venv (avoids conda transitive dependency conflicts entirely):

Python 3.9–3.12 (3.13+ is untested); on Windows activate with `.venv\Scripts\activate`:

```bash
python3.12 -m venv .venv
source .venv/bin/activate
pip install "sulci[sqlite]" anthropic
python your_script.py
```

### `huggingface/tokenizers: The current process just got forked...` warning

Harmless — suppress it with:

```bash
export TOKENIZERS_PARALLELISM=false
```

### `anthropic.OverloadedError: Error code: 529`

Transient API congestion — not a Sulci Cache issue. Wait a moment and retry, or check [status.anthropic.com](https://status.anthropic.com).

### `zsh: no matches found: sulci[chroma]`

Wrap extras in quotes — unquoted, zsh treats the brackets as a glob pattern:

```bash
pip install "sulci[chroma]"
```

### `pytest: command not found`

```bash
python -m pytest tests/ -v
```

---

## Contributing

Start with [`LOCAL_SETUP.md`](./LOCAL_SETUP.md) to get a working environment, then see [`CONTRIBUTING.md`](./CONTRIBUTING.md) for adding a backend, pre-publish review, and releasing.

---

## License

Apache License 2.0 — see [`LICENSE`](./LICENSE).

Copyright 2026 Sulci Labs Inc.

U.S. Patent Application No. 64/018,452 (pending) covers the
context-aware semantic caching algorithm. The application is currently
assigned to Kathiravan Sengodan, not to Sulci Labs Inc. Apache 2.0
grants users a royalty-free patent license for use of this code.

## Trademarks

Sulci&trade;, the Sulci logo, and related marks are common-law (unregistered)
trademarks of Sulci Labs Inc. Apache 2.0 does not grant trademark rights
(Section 6). Nominative fair use is fine —
you may accurately say your product uses Sulci — but the name and marks may
not be used to imply endorsement, sponsorship, or affiliation without prior
written permission. See [`NOTICE`](./NOTICE).

---

## Links

- **Website:** [sulci.io](https://sulci.io?utm_source=github&utm_medium=readme&utm_campaign=oss)
- **Sign up (free key):** [sulci.io/signup](https://sulci.io/signup?utm_source=github&utm_medium=readme&utm_campaign=oss)
- **API:** [api.sulci.io](https://api.sulci.io)
- **PyPI:** [sulci](https://pypi.org/project/sulci/)
- **GitHub:** [sulci-io/sulci-oss](https://github.com/sulci-io/sulci-oss)
- **Issues:** [github.com/sulci-io/sulci-oss/issues](https://github.com/sulci-io/sulci-oss/issues)
