chore: import upstream snapshot with attribution
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# lean-ctx (Python SDK)
|
||||
|
||||
Context compression for AI agents — a thin, dependency-free client for the local
|
||||
[lean-ctx](https://leanctx.com) daemon.
|
||||
|
||||
```bash
|
||||
pip install lean-ctx-sdk
|
||||
```
|
||||
|
||||
## Drop-in `compress(messages, model)`
|
||||
|
||||
Compress a chat-style `messages` array before sending it to any model. Only text
|
||||
payloads are rewritten through lean-ctx's deterministic funnel; images,
|
||||
tool-call blocks and ids pass through untouched, and the output is byte-stable so
|
||||
it stays friendly to provider prompt caching.
|
||||
|
||||
```python
|
||||
from lean_ctx import compress
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": large_log_or_file_dump},
|
||||
]
|
||||
|
||||
messages = compress(messages, model="claude-sonnet-4")
|
||||
# → send `messages` to your provider as usual
|
||||
```
|
||||
|
||||
Works with both OpenAI-style (`content: "string"`) and Anthropic-style
|
||||
(`content: [{type: "text", …}, {type: "tool_result", …}]`) messages.
|
||||
|
||||
### Token-savings stats
|
||||
|
||||
```python
|
||||
from lean_ctx import ProxyClient
|
||||
|
||||
result = ProxyClient().compress(messages, model="gpt-4o")
|
||||
print(result.saved_tokens, result.saved_pct) # e.g. 1840 63.1
|
||||
messages = result.messages
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The endpoint and session token are auto-discovered from the running daemon. Every
|
||||
step is overridable:
|
||||
|
||||
| Setting | Env var | Default |
|
||||
| --- | --- | --- |
|
||||
| Proxy URL | `LEAN_CTX_PROXY_URL` | `http://127.0.0.1:<port>` |
|
||||
| Proxy port | `LEAN_CTX_PROXY_PORT` | `config.toml` `proxy_port`, else UID-derived |
|
||||
| Session token | `LEAN_CTX_PROXY_TOKEN` | `<data_dir>/session_token` |
|
||||
|
||||
Or pass them explicitly (useful in CI / against a remote proxy):
|
||||
|
||||
```python
|
||||
compress(messages, base_url="http://127.0.0.1:4444", token="…")
|
||||
```
|
||||
|
||||
If the daemon is not running, `compress()` raises `LeanCtxConnectionError`; an
|
||||
unauthenticated request raises `LeanCtxAuthError`. Both subclass `LeanCtxError`.
|
||||
|
||||
## Framework integrations
|
||||
|
||||
### LiteLLM
|
||||
|
||||
Compress requests transparently with a `CustomLogger` (`pip install lean-ctx-sdk[litellm]`):
|
||||
|
||||
```python
|
||||
import litellm
|
||||
from lean_ctx import LeanCtxLiteLLMHandler
|
||||
|
||||
litellm.callbacks = [LeanCtxLiteLLMHandler(model="gpt-4o")]
|
||||
# every completion now sends compressed messages
|
||||
```
|
||||
|
||||
For non-proxy code, `compress_request_data(data)` rewrites the `messages` of any
|
||||
OpenAI-style request dict in place.
|
||||
|
||||
### LangChain
|
||||
|
||||
```python
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from lean_ctx import compress_messages
|
||||
|
||||
messages = compress_messages(
|
||||
[
|
||||
SystemMessage(content="You are a helpful assistant."),
|
||||
HumanMessage(content=large_log_or_file_dump),
|
||||
],
|
||||
model="gpt-4o",
|
||||
)
|
||||
```
|
||||
|
||||
In both adapters a compaction failure never breaks the call — the original
|
||||
messages are kept.
|
||||
|
||||
## CLI helpers
|
||||
|
||||
`LeanCtxClient` wraps the `lean-ctx` binary for `read` / `search` / `shell` /
|
||||
`gain` / `benchmark`. The `LeanCtxRetriever` (LangChain) and `LeanCtxNodeParser`
|
||||
(LlamaIndex) retrieval adapters are available via the `langchain` / `llamaindex`
|
||||
extras.
|
||||
|
||||
## Learn more
|
||||
|
||||
- [compress() SDK cookbook](https://github.com/yvgude/lean-ctx/blob/main/docs/guides/compress-sdk.md) — Python + TypeScript recipes
|
||||
- [lean-ctx vs Headroom](https://github.com/yvgude/lean-ctx/blob/main/docs/comparisons/vs-headroom.md) — comparison + reproducible benchmark
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,2 @@
|
||||
# Ensures the package root is importable so `import lean_ctx` resolves when
|
||||
# pytest is run from anywhere inside packages/python-lean-ctx.
|
||||
@@ -0,0 +1,30 @@
|
||||
"""lean-ctx SDK — context compression for AI agents and frameworks.
|
||||
|
||||
Drop-in usage::
|
||||
|
||||
from lean_ctx import compress
|
||||
messages = compress(messages, model="claude-sonnet-4")
|
||||
"""
|
||||
|
||||
from lean_ctx.client import LeanCtxClient
|
||||
from lean_ctx.errors import LeanCtxAuthError, LeanCtxConnectionError, LeanCtxError
|
||||
from lean_ctx.langchain import LeanCtxRetriever, compress_messages
|
||||
from lean_ctx.litellm import LeanCtxLiteLLMHandler, compress_request_data
|
||||
from lean_ctx.llamaindex import LeanCtxNodeParser
|
||||
from lean_ctx.proxy import CompressResult, ProxyClient, compress
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__all__ = [
|
||||
"compress",
|
||||
"ProxyClient",
|
||||
"CompressResult",
|
||||
"LeanCtxClient",
|
||||
"LeanCtxRetriever",
|
||||
"compress_messages",
|
||||
"LeanCtxLiteLLMHandler",
|
||||
"compress_request_data",
|
||||
"LeanCtxNodeParser",
|
||||
"LeanCtxError",
|
||||
"LeanCtxConnectionError",
|
||||
"LeanCtxAuthError",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Core client for interacting with the lean-ctx daemon."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class LeanCtxClient:
|
||||
"""Thin wrapper around the lean-ctx CLI for programmatic access."""
|
||||
|
||||
def __init__(self, binary: str = "lean-ctx", project_root: Optional[str] = None):
|
||||
self.binary = binary
|
||||
self.project_root = project_root or str(Path.cwd())
|
||||
|
||||
def read(self, path: str, mode: str = "auto") -> str:
|
||||
return self._run(["read", path, "--mode", mode])
|
||||
|
||||
def search(self, pattern: str, path: Optional[str] = None) -> str:
|
||||
args = ["grep", pattern]
|
||||
if path:
|
||||
args.append(path)
|
||||
return self._run(args)
|
||||
|
||||
def shell(self, command: str) -> str:
|
||||
return self._run(["-c", command])
|
||||
|
||||
def gain(self) -> dict:
|
||||
output = self._run(["gain", "--json"])
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": output}
|
||||
|
||||
def benchmark(self, path: Optional[str] = None, json_output: bool = True) -> dict:
|
||||
args = ["benchmark", "eval"]
|
||||
if path:
|
||||
args.append(path)
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
output = self._run(args)
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": output}
|
||||
|
||||
def _run(self, args: list[str]) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.binary] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=self.project_root,
|
||||
timeout=30,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
f"lean-ctx binary not found at '{self.binary}'. "
|
||||
"Install: curl -fsSL https://leanctx.com/install.sh | sh"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("lean-ctx command timed out after 30s")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Zero-dependency discovery of the local lean-ctx proxy endpoint.
|
||||
|
||||
Mirrors the daemon's own resolution so ``compress()`` works out of the box once
|
||||
``lean-ctx proxy enable`` has run, while every step stays overridable via the
|
||||
same environment variables the CLI honours:
|
||||
|
||||
* URL — ``LEAN_CTX_PROXY_URL`` else ``http://127.0.0.1:<port>``
|
||||
* Port — ``LEAN_CTX_PROXY_PORT`` → ``config.toml`` ``proxy_port`` → UID-derived
|
||||
* Token — ``LEAN_CTX_PROXY_TOKEN`` → ``<data_dir>/session_token``
|
||||
* Dirs — ``LEAN_CTX_DATA_DIR`` / XDG, matching the Rust ``data_dir`` rules
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Base port the daemon derives per-UID (see proxy_setup::uid_based_port).
|
||||
_DEFAULT_PORT = 4444
|
||||
|
||||
|
||||
def _candidate_dirs() -> List[Path]:
|
||||
"""Ordered data/config directories the daemon may have written to."""
|
||||
dirs: List[Path] = []
|
||||
env = os.environ.get("LEAN_CTX_DATA_DIR", "").strip()
|
||||
if env:
|
||||
dirs.append(Path(env))
|
||||
|
||||
home = Path.home()
|
||||
dirs.append(home / ".lean-ctx")
|
||||
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME", "").strip()
|
||||
dirs.append(Path(xdg_data) / "lean-ctx" if xdg_data else home / ".local" / "share" / "lean-ctx")
|
||||
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
||||
dirs.append(Path(xdg_config) / "lean-ctx" if xdg_config else home / ".config" / "lean-ctx")
|
||||
|
||||
# De-duplicate while preserving order.
|
||||
seen = set()
|
||||
unique: List[Path] = []
|
||||
for d in dirs:
|
||||
if d not in seen:
|
||||
seen.add(d)
|
||||
unique.append(d)
|
||||
return unique
|
||||
|
||||
|
||||
def _uid_port() -> int:
|
||||
"""Replicate proxy_setup::uid_based_port (UID 1000 → 4444, +offset, base for <1000)."""
|
||||
getuid = getattr(os, "getuid", None)
|
||||
if getuid is None: # Windows
|
||||
return _DEFAULT_PORT
|
||||
uid = getuid()
|
||||
offset = (uid - 1000) % 1000 if uid >= 1000 else 0
|
||||
return _DEFAULT_PORT + offset
|
||||
|
||||
|
||||
def _config_port() -> Optional[int]:
|
||||
"""Read a top-level ``proxy_port`` from the first config.toml found."""
|
||||
for directory in _candidate_dirs():
|
||||
try:
|
||||
text = (directory / "config.toml").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
continue
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("proxy_port"):
|
||||
continue
|
||||
try:
|
||||
value = stripped.split("=", 1)[1].strip().strip("\"'")
|
||||
return int(value)
|
||||
except (IndexError, ValueError):
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def resolve_port() -> int:
|
||||
env = os.environ.get("LEAN_CTX_PROXY_PORT", "").strip()
|
||||
if env:
|
||||
try:
|
||||
return int(env)
|
||||
except ValueError:
|
||||
pass
|
||||
cfg = _config_port()
|
||||
if cfg is not None:
|
||||
return cfg
|
||||
return _uid_port()
|
||||
|
||||
|
||||
def resolve_base_url(base_url: Optional[str] = None) -> str:
|
||||
if base_url:
|
||||
return base_url.rstrip("/")
|
||||
env = os.environ.get("LEAN_CTX_PROXY_URL", "").strip()
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
return f"http://127.0.0.1:{resolve_port()}"
|
||||
|
||||
|
||||
def resolve_token(token: Optional[str] = None) -> Optional[str]:
|
||||
if token:
|
||||
return token
|
||||
env = os.environ.get("LEAN_CTX_PROXY_TOKEN", "").strip()
|
||||
if env:
|
||||
return env
|
||||
for directory in _candidate_dirs():
|
||||
try:
|
||||
value = (directory / "session_token").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Exception hierarchy for the lean-ctx SDK."""
|
||||
|
||||
|
||||
class LeanCtxError(Exception):
|
||||
"""Base class for every error raised by the lean-ctx SDK."""
|
||||
|
||||
|
||||
class LeanCtxConnectionError(LeanCtxError):
|
||||
"""The local lean-ctx proxy could not be reached.
|
||||
|
||||
Usually means the daemon is not running (``lean-ctx proxy enable``) or the
|
||||
discovered host/port is wrong (override with ``LEAN_CTX_PROXY_URL``).
|
||||
"""
|
||||
|
||||
|
||||
class LeanCtxAuthError(LeanCtxError):
|
||||
"""The proxy rejected the request (missing or invalid session token).
|
||||
|
||||
Provide the token explicitly, or export ``LEAN_CTX_PROXY_TOKEN`` so the SDK
|
||||
can authenticate against the loopback proxy.
|
||||
"""
|
||||
@@ -0,0 +1,92 @@
|
||||
"""LangChain integration for lean-ctx."""
|
||||
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from lean_ctx.client import LeanCtxClient
|
||||
from lean_ctx.proxy import compress as _compress_dicts
|
||||
|
||||
|
||||
def _reattach_content(originals: List[Any], compressed: List[Any]) -> List[Any]:
|
||||
"""Return clones of ``originals`` with ``content`` taken from ``compressed``.
|
||||
|
||||
Pydantic ``model_copy`` preserves each message's type and metadata; only the
|
||||
textual content is swapped. If the conversion changed the message count (e.g.
|
||||
tool-call splitting) the mapping is ambiguous, so the originals are returned
|
||||
unchanged rather than risk corrupting the transcript.
|
||||
"""
|
||||
if len(originals) != len(compressed):
|
||||
return list(originals)
|
||||
out: List[Any] = []
|
||||
for original, comp in zip(originals, compressed):
|
||||
new_content = comp.get("content") if isinstance(comp, dict) else None
|
||||
if new_content is None or not hasattr(original, "model_copy"):
|
||||
out.append(original)
|
||||
else:
|
||||
out.append(original.model_copy(update={"content": new_content}))
|
||||
return out
|
||||
|
||||
|
||||
def compress_messages(messages: List[Any], model: Optional[str] = None, **kwargs: Any) -> List[Any]:
|
||||
"""Compress the textual content of a list of LangChain ``BaseMessage`` objects.
|
||||
|
||||
Converts to the OpenAI wire shape, runs the deterministic proxy compression,
|
||||
and returns new messages with only their ``content`` rewritten. Requires
|
||||
``langchain-core``. Extra keyword arguments (``base_url``, ``token``,
|
||||
``timeout``) are forwarded to :func:`lean_ctx.compress`.
|
||||
"""
|
||||
try:
|
||||
from langchain_core.messages import convert_to_openai_messages
|
||||
except ImportError as exc: # pragma: no cover - optional dependency
|
||||
raise ImportError("langchain-core is required: pip install langchain-core") from exc
|
||||
|
||||
openai_messages = convert_to_openai_messages(messages)
|
||||
compressed = _compress_dicts(openai_messages, model, **kwargs)
|
||||
return _reattach_content(messages, compressed)
|
||||
|
||||
try:
|
||||
from langchain_core.retrievers import BaseRetriever
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
||||
|
||||
class LeanCtxRetriever(BaseRetriever):
|
||||
"""LangChain retriever backed by lean-ctx hybrid search."""
|
||||
|
||||
client: LeanCtxClient = None
|
||||
top_k: int = 10
|
||||
|
||||
def __init__(self, project_root: Optional[str] = None, top_k: int = 10, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.client = LeanCtxClient(project_root=project_root)
|
||||
self.top_k = top_k
|
||||
|
||||
def _get_relevant_documents(
|
||||
self, query: str, *, run_manager: CallbackManagerForRetrieverRun
|
||||
) -> list[Document]:
|
||||
result = self.client.search(query)
|
||||
documents = []
|
||||
for line in result.split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
file_path, line_num, content = parts[0], parts[1], parts[2]
|
||||
documents.append(
|
||||
Document(
|
||||
page_content=content.strip(),
|
||||
metadata={"source": file_path, "line": line_num},
|
||||
)
|
||||
)
|
||||
else:
|
||||
documents.append(Document(page_content=line.strip()))
|
||||
|
||||
return documents[: self.top_k]
|
||||
|
||||
except ImportError:
|
||||
|
||||
class LeanCtxRetriever:
|
||||
"""Stub: install langchain-core for full integration."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
raise ImportError(
|
||||
"langchain-core is required: pip install langchain-core"
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""LiteLLM integration for lean-ctx.
|
||||
|
||||
Compresses the ``messages`` of an outbound LiteLLM request through the local
|
||||
proxy before it is sent to the provider. Two entry points:
|
||||
|
||||
* :func:`compress_request_data` — framework-agnostic helper that rewrites a
|
||||
request ``dict`` in place (testable without LiteLLM installed).
|
||||
* :class:`LeanCtxLiteLLMHandler` — a LiteLLM ``CustomLogger`` that hooks
|
||||
``async_pre_call_hook`` for use with LiteLLM Proxy or ``litellm.callbacks``.
|
||||
|
||||
Register programmatically::
|
||||
|
||||
import litellm
|
||||
from lean_ctx.litellm import LeanCtxLiteLLMHandler
|
||||
|
||||
litellm.callbacks = [LeanCtxLiteLLMHandler(model="gpt-4o")]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .errors import LeanCtxError
|
||||
from .proxy import ProxyClient
|
||||
|
||||
_PRECALL_TYPES = ("completion", "text_completion")
|
||||
|
||||
|
||||
def compress_request_data(
|
||||
data: Dict[str, Any],
|
||||
*,
|
||||
client: Optional[ProxyClient] = None,
|
||||
model: Optional[str] = None,
|
||||
raise_on_error: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compress ``data["messages"]`` in place and return ``data``.
|
||||
|
||||
Mirrors the LiteLLM/OpenAI request shape. On a proxy failure the messages are
|
||||
left untouched — a compaction hiccup must never block the LLM call — unless
|
||||
``raise_on_error`` is set.
|
||||
"""
|
||||
messages = data.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return data
|
||||
proxy = client or ProxyClient()
|
||||
try:
|
||||
data["messages"] = proxy.compress(messages, model=model or data.get("model")).messages
|
||||
except LeanCtxError:
|
||||
if raise_on_error:
|
||||
raise
|
||||
return data
|
||||
|
||||
|
||||
try: # pragma: no cover - import wiring, exercised by presence/absence of litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
_BASE: type = CustomLogger
|
||||
_LITELLM_AVAILABLE = True
|
||||
except Exception: # noqa: BLE001 - any litellm import failure means "not available"
|
||||
_BASE = object
|
||||
_LITELLM_AVAILABLE = False
|
||||
|
||||
|
||||
class LeanCtxLiteLLMHandler(_BASE): # type: ignore[misc,valid-type]
|
||||
"""LiteLLM ``CustomLogger`` that compresses requests in ``async_pre_call_hook``.
|
||||
|
||||
The synchronous (urllib) :class:`ProxyClient` call is dispatched to a thread
|
||||
so it never blocks the proxy event loop.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model: Optional[str] = None,
|
||||
raise_on_error: bool = False,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
) -> None:
|
||||
if not _LITELLM_AVAILABLE:
|
||||
raise ImportError("litellm is required: pip install litellm")
|
||||
super().__init__()
|
||||
self._client = ProxyClient(base_url=base_url, token=token)
|
||||
self._model = model
|
||||
self._raise_on_error = raise_on_error
|
||||
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: Any,
|
||||
cache: Any,
|
||||
data: Dict[str, Any],
|
||||
call_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
if call_type in _PRECALL_TYPES and isinstance(data, dict):
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: compress_request_data(
|
||||
data,
|
||||
client=self._client,
|
||||
model=self._model,
|
||||
raise_on_error=self._raise_on_error,
|
||||
),
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,47 @@
|
||||
"""LlamaIndex integration for lean-ctx."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from lean_ctx.client import LeanCtxClient
|
||||
|
||||
try:
|
||||
from llama_index.core.node_parser import NodeParser
|
||||
from llama_index.core.schema import BaseNode, TextNode, Document
|
||||
|
||||
class LeanCtxNodeParser(NodeParser):
|
||||
"""LlamaIndex node parser using lean-ctx compression modes."""
|
||||
|
||||
client: LeanCtxClient = None
|
||||
mode: str = "map"
|
||||
|
||||
def __init__(self, project_root: Optional[str] = None, mode: str = "map", **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.client = LeanCtxClient(project_root=project_root)
|
||||
self.mode = mode
|
||||
|
||||
def _parse_nodes(self, nodes: list[BaseNode], **kwargs) -> list[BaseNode]:
|
||||
result_nodes = []
|
||||
for node in nodes:
|
||||
if isinstance(node, Document) and hasattr(node, "metadata"):
|
||||
file_path = node.metadata.get("file_path", "")
|
||||
if file_path:
|
||||
compressed = self.client.read(file_path, mode=self.mode)
|
||||
result_nodes.append(
|
||||
TextNode(
|
||||
text=compressed,
|
||||
metadata={**node.metadata, "compression_mode": self.mode},
|
||||
)
|
||||
)
|
||||
continue
|
||||
result_nodes.append(node)
|
||||
return result_nodes
|
||||
|
||||
except ImportError:
|
||||
|
||||
class LeanCtxNodeParser:
|
||||
"""Stub: install llama-index-core for full integration."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
raise ImportError(
|
||||
"llama-index-core is required: pip install llama-index-core"
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Drop-in ``compress(messages, model)`` over the local lean-ctx proxy.
|
||||
|
||||
Posts a chat-style ``messages`` array to the daemon's deterministic
|
||||
``POST /v1/compress`` endpoint and returns the rewritten messages. Only text
|
||||
payloads are compressed; images, tool-call blocks and ids pass through
|
||||
untouched, and the output is byte-stable for provider prompt caching.
|
||||
|
||||
Stdlib-only (``urllib``) so ``pip install lean-ctx-sdk`` pulls in no transitive
|
||||
dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import discovery
|
||||
from .errors import LeanCtxAuthError, LeanCtxConnectionError, LeanCtxError
|
||||
|
||||
Message = Dict[str, Any]
|
||||
|
||||
_DEFAULT_TIMEOUT = 30.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompressResult:
|
||||
"""Result of a ``/v1/compress`` call: rewritten messages plus savings."""
|
||||
|
||||
messages: List[Message]
|
||||
stats: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def original_tokens(self) -> int:
|
||||
return int(self.stats.get("original_tokens", 0))
|
||||
|
||||
@property
|
||||
def compressed_tokens(self) -> int:
|
||||
return int(self.stats.get("compressed_tokens", 0))
|
||||
|
||||
@property
|
||||
def saved_tokens(self) -> int:
|
||||
return int(self.stats.get("saved_tokens", 0))
|
||||
|
||||
@property
|
||||
def saved_pct(self) -> float:
|
||||
return float(self.stats.get("saved_pct", 0.0))
|
||||
|
||||
|
||||
class ProxyClient:
|
||||
"""Reusable client for the local lean-ctx proxy ``/v1/compress`` endpoint.
|
||||
|
||||
Endpoint and token are auto-discovered (env → config → UID/data-dir) and may
|
||||
be overridden explicitly for CI or remote proxies.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> None:
|
||||
self.base_url = discovery.resolve_base_url(base_url)
|
||||
self.token = discovery.resolve_token(token)
|
||||
self.timeout = timeout
|
||||
|
||||
def compress(
|
||||
self,
|
||||
messages: List[Message],
|
||||
model: Optional[str] = None,
|
||||
) -> CompressResult:
|
||||
"""Compress ``messages`` and return the rewritten list plus stats."""
|
||||
if not isinstance(messages, list):
|
||||
raise TypeError("messages must be a list of chat-message dicts")
|
||||
|
||||
payload: Dict[str, Any] = {"messages": messages}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
|
||||
data = self._post("/v1/compress", payload)
|
||||
out = data.get("messages")
|
||||
if not isinstance(out, list):
|
||||
raise LeanCtxError("malformed /v1/compress response: 'messages' missing")
|
||||
stats = data.get("stats")
|
||||
return CompressResult(messages=out, stats=stats if isinstance(stats, dict) else {})
|
||||
|
||||
def resolve_reference(self, reference_id: str) -> str:
|
||||
"""Return the original content behind a lean-ctx reference id.
|
||||
|
||||
lean-ctx replaces oversized omitted content with a durable reference; this
|
||||
fetches it back via ``GET /v1/references/{id}``. Raises :class:`LeanCtxError`
|
||||
when the reference is unknown or expired.
|
||||
"""
|
||||
if not reference_id:
|
||||
raise ValueError("reference_id must be a non-empty string")
|
||||
quoted = urllib.parse.quote(reference_id, safe="")
|
||||
request = urllib.request.Request(f"{self.base_url}/v1/references/{quoted}", method="GET")
|
||||
if self.token:
|
||||
request.add_header("Authorization", f"Bearer {self.token}")
|
||||
return self._send(request).decode("utf-8")
|
||||
|
||||
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(f"{self.base_url}{path}", data=body, method="POST")
|
||||
request.add_header("Content-Type", "application/json")
|
||||
if self.token:
|
||||
request.add_header("Authorization", f"Bearer {self.token}")
|
||||
raw = self._send(request)
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise LeanCtxError(f"invalid JSON response from {request.full_url}: {exc}") from exc
|
||||
|
||||
def _send(self, request: urllib.request.Request) -> bytes:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
return response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace").strip()
|
||||
if exc.code in (401, 403):
|
||||
raise LeanCtxAuthError(
|
||||
f"proxy rejected the request (HTTP {exc.code}). "
|
||||
"Set LEAN_CTX_PROXY_TOKEN or pass token=…"
|
||||
) from exc
|
||||
if exc.code == 404:
|
||||
raise LeanCtxError(
|
||||
f"{request.full_url} not found (HTTP 404): {detail}"
|
||||
) from exc
|
||||
raise LeanCtxError(
|
||||
f"{request.get_method()} {request.full_url} failed (HTTP {exc.code}): {detail}"
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise LeanCtxConnectionError(
|
||||
f"could not reach the lean-ctx proxy at {self.base_url} ({exc.reason}). "
|
||||
"Is the daemon running? Try: lean-ctx proxy enable"
|
||||
) from exc
|
||||
|
||||
|
||||
def compress(
|
||||
messages: List[Message],
|
||||
model: Optional[str] = None,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> List[Message]:
|
||||
"""Compress a chat ``messages`` array, returning the rewritten messages.
|
||||
|
||||
Drop-in parity with library-style gateways::
|
||||
|
||||
from lean_ctx import compress
|
||||
messages = compress(messages, model="claude-sonnet-4")
|
||||
|
||||
For token-savings stats, use :class:`ProxyClient` directly::
|
||||
|
||||
from lean_ctx import ProxyClient
|
||||
result = ProxyClient().compress(messages)
|
||||
print(result.saved_pct)
|
||||
"""
|
||||
client = ProxyClient(base_url=base_url, token=token, timeout=timeout)
|
||||
return client.compress(messages, model=model).messages
|
||||
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "lean-ctx-sdk"
|
||||
version = "0.3.0"
|
||||
description = "SDK for lean-ctx — context compression for AI agents"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["llm", "context", "compression", "ai", "mcp"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
langchain = ["langchain-core>=0.2"]
|
||||
llamaindex = ["llama-index-core>=0.10"]
|
||||
litellm = ["litellm>=1.0"]
|
||||
all = ["langchain-core>=0.2", "llama-index-core>=0.10", "litellm>=1.0"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["lean_ctx*"]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Endpoint/token discovery — isolated from the developer's real environment."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from lean_ctx import discovery
|
||||
|
||||
_ENV_KEYS = (
|
||||
"LEAN_CTX_PROXY_URL",
|
||||
"LEAN_CTX_PROXY_PORT",
|
||||
"LEAN_CTX_PROXY_TOKEN",
|
||||
"LEAN_CTX_DATA_DIR",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
"""Clear every discovery env var and root HOME at an empty tmp dir."""
|
||||
for key in _ENV_KEYS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_base_url_explicit_strips_trailing_slash():
|
||||
assert discovery.resolve_base_url("http://host:9/") == "http://host:9"
|
||||
|
||||
|
||||
def test_base_url_from_env(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_PROXY_URL", "http://h:1234/")
|
||||
assert discovery.resolve_base_url() == "http://h:1234"
|
||||
|
||||
|
||||
def test_base_url_defaults_to_loopback(isolated, monkeypatch):
|
||||
monkeypatch.setattr(os, "getuid", lambda: 1000, raising=False)
|
||||
assert discovery.resolve_base_url() == "http://127.0.0.1:4444"
|
||||
|
||||
|
||||
def test_port_env_wins(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_PROXY_PORT", "5005")
|
||||
assert discovery.resolve_port() == 5005
|
||||
|
||||
|
||||
def test_uid_port_matches_rust_formula(isolated, monkeypatch):
|
||||
monkeypatch.setattr(os, "getuid", lambda: 1000, raising=False)
|
||||
assert discovery._uid_port() == 4444
|
||||
monkeypatch.setattr(os, "getuid", lambda: 2999, raising=False)
|
||||
assert discovery._uid_port() == 5443
|
||||
monkeypatch.setattr(os, "getuid", lambda: 500, raising=False)
|
||||
assert discovery._uid_port() == 4444
|
||||
|
||||
|
||||
def test_port_from_config_toml(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_DATA_DIR", str(isolated))
|
||||
(isolated / "config.toml").write_text("proxy_port = 4500\n", encoding="utf-8")
|
||||
assert discovery.resolve_port() == 4500
|
||||
|
||||
|
||||
def test_commented_proxy_port_is_ignored(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_DATA_DIR", str(isolated))
|
||||
(isolated / "config.toml").write_text("# proxy_port = 3128\n", encoding="utf-8")
|
||||
monkeypatch.setattr(os, "getuid", lambda: 1000, raising=False)
|
||||
assert discovery.resolve_port() == 4444
|
||||
|
||||
|
||||
def test_token_env_precedence(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_PROXY_TOKEN", "envtok")
|
||||
assert discovery.resolve_token() == "envtok"
|
||||
|
||||
|
||||
def test_token_from_session_file(isolated, monkeypatch):
|
||||
monkeypatch.setenv("LEAN_CTX_DATA_DIR", str(isolated))
|
||||
(isolated / "session_token").write_text("deadbeef\n", encoding="utf-8")
|
||||
assert discovery.resolve_token() == "deadbeef"
|
||||
|
||||
|
||||
def test_token_absent_returns_none(isolated):
|
||||
assert discovery.resolve_token() is None
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for the LangChain compress integration.
|
||||
|
||||
The content-reattachment logic is unit-tested with a fake message (no LangChain
|
||||
needed); the full ``compress_messages`` path runs against a loopback server when
|
||||
``langchain-core`` is installed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from lean_ctx.langchain import _reattach_content, compress_messages
|
||||
|
||||
|
||||
class _FakeMessage:
|
||||
"""Minimal pydantic-v2-like message: ``content`` plus ``model_copy``."""
|
||||
|
||||
def __init__(self, content, role="user"):
|
||||
self.content = content
|
||||
self.role = role
|
||||
|
||||
def model_copy(self, update):
|
||||
clone = _FakeMessage(self.content, self.role)
|
||||
for key, value in update.items():
|
||||
setattr(clone, key, value)
|
||||
return clone
|
||||
|
||||
def __eq__(self, other):
|
||||
return (
|
||||
isinstance(other, _FakeMessage)
|
||||
and other.content == self.content
|
||||
and other.role == self.role
|
||||
)
|
||||
|
||||
|
||||
def test_reattach_content_swaps_only_content():
|
||||
originals = [_FakeMessage("long original body", "user")]
|
||||
out = _reattach_content(originals, [{"role": "user", "content": "short"}])
|
||||
assert out[0].content == "short"
|
||||
assert out[0].role == "user"
|
||||
assert out[0] is not originals[0]
|
||||
|
||||
|
||||
def test_reattach_content_preserves_originals_on_count_mismatch():
|
||||
originals = [_FakeMessage("a"), _FakeMessage("b")]
|
||||
out = _reattach_content(originals, [{"content": "x"}])
|
||||
assert out == originals
|
||||
|
||||
|
||||
def test_reattach_content_keeps_message_without_model_copy():
|
||||
class _Plain:
|
||||
content = "untouched"
|
||||
|
||||
originals = [_Plain()]
|
||||
out = _reattach_content(originals, [{"content": "new"}])
|
||||
assert out[0].content == "untouched"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_url():
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
out = []
|
||||
for message in body["messages"]:
|
||||
rewritten = dict(message)
|
||||
if isinstance(rewritten.get("content"), str):
|
||||
rewritten["content"] = rewritten["content"][:8]
|
||||
out.append(rewritten)
|
||||
payload = json.dumps({"messages": out, "stats": {}}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = httpd.server_address
|
||||
yield f"http://{host}:{port}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def test_compress_messages_rewrites_content(base_url):
|
||||
pytest.importorskip("langchain_core")
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
messages = [
|
||||
SystemMessage(content="You are a helpful assistant."),
|
||||
HumanMessage(content="this is a long message body"),
|
||||
]
|
||||
out = compress_messages(messages, model="gpt-4o", base_url=base_url)
|
||||
assert type(out[1]).__name__ == "HumanMessage"
|
||||
assert out[1].content == "this is "
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for the LiteLLM integration.
|
||||
|
||||
``compress_request_data`` is exercised end-to-end against a loopback server;
|
||||
the ``LeanCtxLiteLLMHandler`` is tested via its import guard (and its async hook
|
||||
when LiteLLM happens to be installed).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from lean_ctx.errors import LeanCtxConnectionError
|
||||
from lean_ctx.litellm import (
|
||||
_LITELLM_AVAILABLE,
|
||||
LeanCtxLiteLLMHandler,
|
||||
compress_request_data,
|
||||
)
|
||||
from lean_ctx.proxy import ProxyClient
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802 (BaseHTTPRequestHandler API)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
out = []
|
||||
for message in body["messages"]:
|
||||
rewritten = dict(message)
|
||||
if isinstance(rewritten.get("content"), str):
|
||||
rewritten["content"] = rewritten["content"][:8]
|
||||
out.append(rewritten)
|
||||
payload = json.dumps({"messages": out, "stats": {}}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, *args): # silence test output
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_url():
|
||||
httpd = HTTPServer(("127.0.0.1", 0), _Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = httpd.server_address
|
||||
yield f"http://{host}:{port}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def test_compress_request_data_rewrites_messages_in_place(base_url):
|
||||
client = ProxyClient(base_url=base_url)
|
||||
data = {"model": "gpt-4o", "messages": [{"role": "user", "content": "a long message body"}]}
|
||||
out = compress_request_data(data, client=client)
|
||||
assert out is data
|
||||
assert data["messages"] == [{"role": "user", "content": "a long m"}]
|
||||
|
||||
|
||||
def test_compress_request_data_ignores_empty_messages():
|
||||
assert compress_request_data({"messages": []}) == {"messages": []}
|
||||
assert compress_request_data({}) == {}
|
||||
|
||||
|
||||
def test_compress_request_data_passthrough_when_proxy_down():
|
||||
client = ProxyClient(base_url="http://127.0.0.1:1", token="t")
|
||||
data = {"messages": [{"role": "user", "content": "x" * 40}]}
|
||||
out = compress_request_data(data, client=client)
|
||||
assert out["messages"][0]["content"] == "x" * 40
|
||||
|
||||
|
||||
def test_compress_request_data_raise_on_error():
|
||||
client = ProxyClient(base_url="http://127.0.0.1:1", token="t")
|
||||
data = {"messages": [{"role": "user", "content": "x" * 40}]}
|
||||
with pytest.raises(LeanCtxConnectionError):
|
||||
compress_request_data(data, client=client, raise_on_error=True)
|
||||
|
||||
|
||||
def test_handler_requires_litellm():
|
||||
if _LITELLM_AVAILABLE:
|
||||
pytest.skip("litellm installed; the ImportError guard is not exercised")
|
||||
with pytest.raises(ImportError):
|
||||
LeanCtxLiteLLMHandler()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _LITELLM_AVAILABLE, reason="litellm not installed")
|
||||
def test_async_pre_call_hook_compresses(base_url):
|
||||
handler = LeanCtxLiteLLMHandler(base_url=base_url)
|
||||
data = {"messages": [{"role": "user", "content": "a long message body"}]}
|
||||
out = asyncio.run(handler.async_pre_call_hook(None, None, data, "completion"))
|
||||
assert out["messages"][0]["content"] == "a long m"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Transport tests for ProxyClient against a real loopback HTTP server.
|
||||
|
||||
The server is a faithful stand-in for the daemon's ``/v1/compress`` contract
|
||||
(echoes the request, returns a stats block); it exercises request building,
|
||||
auth headers, response parsing and error mapping — the compression itself is
|
||||
covered by the Rust unit tests.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from lean_ctx import ProxyClient, compress
|
||||
from lean_ctx.errors import LeanCtxAuthError, LeanCtxConnectionError, LeanCtxError
|
||||
|
||||
EXPECTED_TOKEN = "secret-token"
|
||||
|
||||
|
||||
class _CompressHandler(BaseHTTPRequestHandler):
|
||||
def do_POST(self): # noqa: N802 (BaseHTTPRequestHandler API)
|
||||
if self.path != "/v1/compress":
|
||||
self.send_error(404)
|
||||
return
|
||||
auth = self.headers.get("Authorization", "")
|
||||
if self.server.require_token and auth != f"Bearer {EXPECTED_TOKEN}":
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
self.server.last_request = {"headers": dict(self.headers), "body": body}
|
||||
|
||||
original = 0
|
||||
compressed = 0
|
||||
out = []
|
||||
for message in body["messages"]:
|
||||
rewritten = dict(message)
|
||||
content = rewritten.get("content")
|
||||
if isinstance(content, str):
|
||||
original += len(content)
|
||||
rewritten["content"] = content[:8]
|
||||
compressed += len(rewritten["content"])
|
||||
out.append(rewritten)
|
||||
|
||||
saved = original - compressed
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
"messages": out,
|
||||
"stats": {
|
||||
"original_tokens": original,
|
||||
"compressed_tokens": compressed,
|
||||
"saved_tokens": saved,
|
||||
"saved_pct": round(saved / original * 100, 1) if original else 0.0,
|
||||
"model": body.get("model"),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def do_GET(self): # noqa: N802 (BaseHTTPRequestHandler API)
|
||||
if not self.path.startswith("/v1/references/"):
|
||||
self.send_error(404)
|
||||
return
|
||||
reference_id = self.path.rsplit("/", 1)[-1]
|
||||
if reference_id == "missing":
|
||||
self._text(404, "Reference expired or not found")
|
||||
return
|
||||
self._text(200, f"ORIGINAL[{reference_id}]")
|
||||
|
||||
def _text(self, status, text):
|
||||
data = text.encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _json(self, status, payload):
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, *args): # silence test output
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server():
|
||||
httpd = HTTPServer(("127.0.0.1", 0), _CompressHandler)
|
||||
httpd.require_token = False
|
||||
httpd.last_request = None
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
host, port = httpd.server_address
|
||||
yield httpd, f"http://{host}:{port}"
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
def test_compress_function_returns_messages(server):
|
||||
httpd, base_url = server
|
||||
out = compress(
|
||||
[{"role": "user", "content": "this is a long message body"}],
|
||||
model="gpt-4o",
|
||||
base_url=base_url,
|
||||
token=EXPECTED_TOKEN,
|
||||
)
|
||||
assert out == [{"role": "user", "content": "this is "}]
|
||||
|
||||
|
||||
def test_client_returns_stats_and_sends_model(server):
|
||||
httpd, base_url = server
|
||||
client = ProxyClient(base_url=base_url, token=EXPECTED_TOKEN)
|
||||
result = client.compress(
|
||||
[{"role": "user", "content": "abcdefghijklmnop"}],
|
||||
model="claude-sonnet-4",
|
||||
)
|
||||
assert result.saved_tokens == len("abcdefghijklmnop") - 8
|
||||
assert result.saved_pct > 0
|
||||
assert httpd.last_request["body"]["model"] == "claude-sonnet-4"
|
||||
assert httpd.last_request["headers"]["Content-Type"] == "application/json"
|
||||
|
||||
|
||||
def test_auth_error_maps_to_exception(server):
|
||||
httpd, base_url = server
|
||||
httpd.require_token = True
|
||||
with pytest.raises(LeanCtxAuthError):
|
||||
compress(
|
||||
[{"role": "user", "content": "needs a valid token here"}],
|
||||
base_url=base_url,
|
||||
token="wrong",
|
||||
)
|
||||
|
||||
|
||||
def test_connection_error_when_daemon_down():
|
||||
# Port 1 is never an open lean-ctx proxy → URLError → ConnectionError.
|
||||
with pytest.raises(LeanCtxConnectionError):
|
||||
compress(
|
||||
[{"role": "user", "content": "x" * 50}],
|
||||
base_url="http://127.0.0.1:1",
|
||||
token="t",
|
||||
)
|
||||
|
||||
|
||||
def test_non_list_messages_rejected():
|
||||
with pytest.raises(TypeError):
|
||||
ProxyClient(base_url="http://127.0.0.1:1", token="t").compress(
|
||||
{"role": "user", "content": "not a list"}
|
||||
)
|
||||
|
||||
|
||||
def test_malformed_response_raises(server):
|
||||
httpd, base_url = server
|
||||
|
||||
class _Bad(_CompressHandler):
|
||||
def do_POST(self): # noqa: N802
|
||||
self._json(200, {"unexpected": True})
|
||||
|
||||
httpd.RequestHandlerClass = _Bad
|
||||
with pytest.raises(LeanCtxError):
|
||||
compress(
|
||||
[{"role": "user", "content": "y" * 40}],
|
||||
base_url=base_url,
|
||||
token=EXPECTED_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_reference_returns_content(server):
|
||||
httpd, base_url = server
|
||||
client = ProxyClient(base_url=base_url, token=EXPECTED_TOKEN)
|
||||
assert client.resolve_reference("abc123") == "ORIGINAL[abc123]"
|
||||
|
||||
|
||||
def test_resolve_reference_missing_raises(server):
|
||||
httpd, base_url = server
|
||||
client = ProxyClient(base_url=base_url, token=EXPECTED_TOKEN)
|
||||
with pytest.raises(LeanCtxError):
|
||||
client.resolve_reference("missing")
|
||||
|
||||
|
||||
def test_resolve_reference_empty_id_rejected():
|
||||
client = ProxyClient(base_url="http://127.0.0.1:1", token="t")
|
||||
with pytest.raises(ValueError):
|
||||
client.resolve_reference("")
|
||||
Reference in New Issue
Block a user