compression with a quality contract

Integrations — one proxy, every SDK

Start distil proxy once and point any SDK's baseURL at it. No library changes, no monkey-patching — compression happens at the network layer.

Cross-SDK proxy diagram

How it works

The proxy (distil proxy, default http://127.0.0.1:8788) is a local HTTP server. It intercepts the three compressible paths across all major LLM APIs:

All other paths and HTTP verbs pass through unchanged. Your API key travels in the request headers exactly as normal — the proxy never logs or stores it.

Zero SDK changes beyond baseURL. The proxy is transparent: the SDK sees the same wire format it always expects. Auth headers, streaming, tool use, and all other features work as-is.

SDK integration matrix

SDK / Framework Language Setting Value Example
Anthropic Python SDK Python base_url= http://127.0.0.1:8788 python_anthropic.py
Anthropic TypeScript SDK (@anthropic-ai/sdk) TypeScript baseURL in new Anthropic({…}) http://127.0.0.1:8788 js_anthropic.ts
Claude Agent SDK / claude -p (headless) Python / TS / CLI distil wrap -- <cmd> or ANTHROPIC_BASE_URL http://127.0.0.1:8788 python_claude_agent_sdk.py
OpenAI Python SDK Python base_url= http://127.0.0.1:8788/v1 python_openai.py
LiteLLM Python api_base= http://127.0.0.1:8788 python_litellm.py
Vercel AI SDK (@ai-sdk/anthropic) TypeScript baseURL in createAnthropic({…}) http://127.0.0.1:8788 js_vercel_ai_sdk.ts
LangChain.js (@langchain/anthropic) TypeScript anthropicApiUrl in ChatAnthropic({…}) http://127.0.0.1:8788 js_langchain.ts
Google Gemini REST (google-generativeai) Python / curl api_endpoint in client_options http://127.0.0.1:8788 (upstream: https://generativelanguage.googleapis.com) python_gemini.py

Snippets

Anthropic Python SDK

import anthropic

client = anthropic.Anthropic(
    api_key="sk-ant-…",
    base_url="http://127.0.0.1:8788",
)
response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello!"}],
)

OpenAI Python SDK

import openai

client = openai.OpenAI(
    api_key="sk-…",
    base_url="http://127.0.0.1:8788/v1",
)
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello!"}],
)

LiteLLM

import litellm

response = litellm.completion(
    model="claude-opus-4-5",
    api_base="http://127.0.0.1:8788",
    api_key="sk-ant-…",
    messages=[{"role": "user", "content": "Hello!"}],
)

Running the standalone LiteLLM Proxy instead?

Same mechanism, one YAML field: point each model's api_base at distil proxy, and every request routed through the LiteLLM Proxy is compressed transparently.

# Terminal 1 — distil sits in front of the real upstream
distil proxy --port 8788 --upstream https://api.anthropic.com

# config.yaml — Terminal 2
model_list:
  - model_name: claude-opus-4-5
    litellm_params:
      model: anthropic/claude-opus-4-5
      api_base: http://127.0.0.1:8788
      api_key: os.environ/ANTHROPIC_API_KEY

# Terminal 2 — start the LiteLLM Proxy against that config
litellm --config config.yaml

Vercel AI SDK (TypeScript)

import { createAnthropic } from "@ai-sdk/anthropic";
import { generateText } from "ai";

const anthropic = createAnthropic({
  baseURL: "http://127.0.0.1:8788",
  apiKey: process.env.ANTHROPIC_API_KEY,
});

const { text } = await generateText({
  model: anthropic("claude-opus-4-5"),
  prompt: "Hello!",
});

LangChain.js (TypeScript)

import { ChatAnthropic } from "@langchain/anthropic";

const model = new ChatAnthropic({
  model: "claude-opus-4-5",
  apiKey: process.env.ANTHROPIC_API_KEY,
  anthropicApiUrl: "http://127.0.0.1:8788",
  // older versions: clientOptions: { baseURL: "http://127.0.0.1:8788" }
});

const response = await model.invoke([
  ["human", "Hello!"],
]);

Google Gemini REST

# Start proxy pointing at the Gemini API
distil proxy --port 8788 --upstream https://generativelanguage.googleapis.com
import google.generativeai as genai

genai.configure(
    transport="rest",
    client_options={"api_endpoint": "http://127.0.0.1:8788"},
)
model = genai.GenerativeModel("gemini-1.5-pro")
response = model.generate_content("Hello!")

See examples/python_gemini.py for a full runnable example including tool-use turns. The proxy transparently compresses text parts (Tier-0 lossless) and functionResponse payloads (Tier-1 reversible digest). functionCall, inlineData, fileData, model-authored text, and systemInstruction are always passed through unchanged.


Claude Code plugin

The first-class integration: a session-first savings status line plus slash commands, shipped as a marketplace-format plugin (plugins/distil). Wire it in one step with distil setup (or distil onboard), then route the agent through compression with distil wrap -- claude.

CommandWhat it does
/distil-onboardSet up distil + a guided, tailored tour
/distilSavings report + how to route more traffic through distil
/distil-statsFull breakdown — tokens, cost, runs, per-trajectory bars
/distil-shadowLive decision-equivalence: did compression preserve the next action?
/distil-dashboardHTML savings page — session, lifetime, and decision-equivalence cards
/distil-doctorDiagnose the setup — ledger, shadow validation, proxy round-trip, wiring
/distil-certifyTrajectory-level certificate: bound how many solvable tasks compression may cost
/distil-badgeShareable badge of your measured savings

One pattern in every state: distil · <live> · total ▼<lifetime>. The live segment aggregates recent activity (last 15 min, all terminals), so it never flickers between sessions:

StateYou seeMeans
savingdistil · ▼12.0K · 40% smaller · $0.31 · total ▼27.0M · ✓eq 99%compressing your recent traffic
watchingdistil · ✓ on · waiting for a large read · total ▼27.0Mon, but no large content yet — savings come from big file/command output
idledistil · ✓ on · total ▼27.0Mset up and on, no recent traffic
not routeddistil · off — session not routed · total ▼27.0Mthis session goes straight to the provider — start it with distil wrap (or the always-on env) to compress. "on" always means routed, never merely installed.

= tokens saved · total = lifetime · ✓eq = decision-equivalence (shown only past 25 shadow samples — a rate over a handful is noise). Sharing the line with git/cwd/model? Set DISTIL_STATUSLINE=minimal for a two-fact segment: distil ▼75.0K · 27.0M total.


MCP server

A zero-dependency Model Context Protocol server (stdlib only — no SDK) exposes distil's reversible compression to any MCP client (Claude Desktop, IDEs, agents) over stdio:

claude mcp add distil -- distil mcp   # Claude Code — one line, done

Every other client takes the same stdio config — Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json), Cursor (.cursor/mcp.json), VS Code (.vscode/mcp.json):

{
  "mcpServers": {
    "distil": { "command": "distil", "args": ["mcp"] }
  }
}

No distil install? Run it from PyPI on demand — "command": "uvx", "args": ["--from", "distil-llm", "distil", "mcp"]. Restart the client after editing. To check the server without a client at all:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | distil mcp
ToolWhat it doesCalled when
distil_compress(text)Compact digest + an 8-hex handle; the original is stored locally (encrypted, 0600) and never returned until asked A tool returned something huge and carrying it verbatim is wasteful
distil_expand(handle)The exact original bytes — not a summaryThe digest lost a detail the agent now needs: a line, a value, a stack frame
distil_savings()Cumulative tokens/dollars from the local ledger Someone asks what distil has saved

Each tool carries MCP annotations (readOnlyHint, idempotentHint, openWorldHint: false), so a client knows distil_expand is a safe, repeatable, offline read instead of inferring it from prose.

This is the recall path, not the savings path. The MCP server does not compress your agent's traffic — distil wrap does that transparently, with no tool calls. What the MCP server adds is the other half: any agent, including one you never wrapped, can call distil_expand on a handle it sees in context and get the original back. Handles survive restarts and cross processes, and age out after DISTIL_RESTORE_TTL_DAYS (default 14).


In-process hooks (LiteLLM · LangChain · LangGraph)

Prefer not to run a sidecar? Compress the request in-process — the same reversible compression, no proxy. Every helper lazy-imports (or duck-types) its framework, so distil stays zero-dep.

# LiteLLM — drop-in for litellm.completion
from distil.integrations import litellm as distil_litellm
resp = distil_litellm.completion(model="claude-opus-4-8", messages=[...],
                                 distil_verbatim=True)  # optional, Tier-0 only

# LangChain — compress a message list before the model call (duck-typed)
from distil.integrations.langchain import compress_messages
msgs = compress_messages(state["messages"], verbatim=True)

# LangGraph — compress graph state right before the model node
from distil.integrations.langgraph import pre_model_hook
agent = create_react_agent(model, tools, pre_model_hook=pre_model_hook())

Tool/function messages get the reversible Tier-1 digest; human/system messages get Tier-0 lossless; the model's own words are never rewritten. compress() (LiteLLM), compress_messages() (LangChain), and pre_model_hook() (LangGraph) are framework-free and unit-tested. The LangGraph hook returns only the updated message list, so every other state field is left intact.


Observability headers

The proxy adds up to 9 response headers per compressed response. The first two appear on every compressed request; the rest are conditional:

HeaderMeaningCondition
x-distil-compressed: 1 Compression was applied this turn. Always (on compressed requests)
x-distil-tokens-saved: <n> Estimated input tokens saved (heuristic tokenizer). Always (on compressed requests)
x-distil-expanded: 1 A digest was resolved via the transparent expand loop. When --expand fired
x-distil-cache-prefix-msgs: <n> Leading messages left byte-identical vs the previous turn (the prompt-cache-read region) — the verifiable benefit of a prefix-freeze router, content-free. With --session-delta
x-distil-cache-refs: <n> Total cache-delta references this turn. With --session-delta
x-distil-cache-delta: <n> Delta-encoded references this turn. With --session-delta
x-distil-cache-tokens-saved: <n> Tokens saved by cache-delta encoding. With --session-delta
x-distil-output-shaping: light|aggressive Output shaping was injected at this level. When --shape-output fired
x-distil-shadow: sampled This request was sampled for shadow-mode decision-equivalence. When --shadow rate triggered

The managed gateway (distil gateway) additionally adds x-distil-tenant: <id> for per-tenant accounting.


Node.js / TypeScript

Distil's universal path is the proxy — it's language-agnostic, so JS/TS stacks need no Distil-specific package. Start the proxy (or wrap your agent) and point your SDK's baseURL at it:

# start the proxy (needs Python 3.11+ — see Install)
distil proxy --port 8788 --upstream https://api.anthropic.com
// then in your Node/TS app — no code change beyond baseURL
const client = new Anthropic({ baseURL: "http://localhost:8788" });

Or wrap an existing CLI/agent in one shot: distil wrap -- <your-command> starts the proxy and injects ANTHROPIC_BASE_URL automatically.

Homebrew

brew tap dshakes/tap
brew install dshakes/tap/distil
distil proxy --port 8788