Caveman
CLI & SDKsPython SDK

Python SDK

A stdlib-only Python client for the Caveman gateway. It traces workflows, searches tool catalogs, delegates compression, proxies provider calls, and builds OTLP JSON without third-party dependencies.

The import package is caveman_cloud. It uses only the standard library (urllib.request for HTTP, no requests, no httpx, dependencies = []) and requires Python 3.13 or newer. It wraps the gateway's /sdk/v1/* contract. It is not a compressor, tokenizer, or ranker; those decisions stay in the gateway and engine.

It mirrors the TypeScript SDK field-for-field — the snake_case names are the only difference. The two are kept in lockstep by a shared parity suite; a field that exists in one and not the other fails CI.

Install

The distribution name in pyproject.toml is caveman, but that PyPI name is not yet verified as ours, so there is no pip install caveman to rely on today. Install from source in editable mode and import caveman_cloud:

terminal
# from public/sdk/python (the package dir)
pip install -e .
# run the tests (Python >= 3.13)
pytest
python
from caveman_cloud import Cave, CompressResult, ToolSearchResult

Import name vs. distribution name

The import package stays caveman_cloud regardless of how it is installed. The caveman distribution name on PyPI is reserved/unverified — install from source until ownership is confirmed.

The client

Cave(api_key, base_url, agent) takes three required fields — an API key, the gateway base URL, and an agent slug that labels every span. default_workflow, retention, and control_url (the control-plane host the jobs client targets) are optional.

python
from caveman_cloud import Cave

cave = Cave(
api_key=os.environ["CAVE_API_KEY"],
base_url="http://127.0.0.1:8787",
agent="my-service",
default_workflow="support-triage", # optional; defaults to "unlabeled-workflow"
)

Tracing

cave.trace(workflow, tags) is a context manager yielding a Trace. Its tool(), model, artifacts, and checkpoint/expand helpers attribute everything to the trace's workflow. Tool spans are POSTed to /sdk/v1/events; telemetry failures are swallowed so they never break your agent.

python
with cave.trace("refund-flow", {"tier": "pro"}) as trace:
order = trace.tool("lookup_order", {"read_only": True}, lambda: db_find(order_id))

reply = trace.model["openai"].responses.create(
{"model": "gpt-4.1", "input": f"Summarize order {order_id}"}
)

Checkpoints

trace.checkpoint(messages, options) POSTs a conversation to /sdk/v1/checkpoints; the gateway persists it and returns a reversible source_ref. trace.expand(source_ref) is the other half — it GETs the stored {source_ref, version, messages, checkpoint} back. A checkpoint that cannot be expanded is a bug.

python
cp = trace.checkpoint(messages, {"reason": "pre-summarization"})
# ... later, or in a peer step ...
restored = trace.expand(cp["source_ref"])

Artifacts

trace.artifacts.page(value, options) pages a large payload out to /sdk/v1/artifacts and returns a compact [cave-artifact …] stub the model can expand on demand. strategy="verbatim" bypasses storage and returns the value unchanged. (trace.page_artifact is a backwards-compatible alias.)

python
stub = trace.artifacts.page(
big_search_result,
{"source": "vector-search", "content_type": "application/json", "strategy": "json-index"},
)

cave.tools(catalog, *, strategy="all", initial_tool_count=8) returns a handle with .strategy, .initial, and .search(). strategy="all" puts the whole catalog in .initial; strategy="deferred" puts only the always_load tools plus the first initial_tool_count. Either way, .search() calls the gateway with the full catalog.

python
from caveman_cloud import CaveTool

catalog = [CaveTool(name="refund", description="Refund a charge", input_schema={...})]
tools = cave.tools(catalog, strategy="deferred")
# send tools.initial on the first turn

found: ToolSearchResult = tools.search("refund a charge", max_tools=5, ranker="bm25")
print(found.tools, found.saved_tokens, found.reduction_pct)

The ranker ("bm25" | "embeddings") is passed through to the gateway verbatim; the gateway honors "embeddings" only when it has an embedding provider wired. The SDK never computes similarity itself. cave.tool_search(tools, query, ...) is the flat variant for when you manage the catalog outside a handle.

Compression

cave.compress(payload, *, content_type=None) POSTs to /sdk/v1/compress and returns the Engine's report. The SDK delegates — it never reimplements a compressor, because that would fork behavior across surfaces.

python
result: CompressResult = cave.compress(big_json, content_type="json")
if result.recovery_handle:
# dropped detail is recoverable byte-exact via the handle
...
print(result.ratio, result.basis) # e.g. 0.93, "inferred"

Honesty rule

compress() is byte-safe. On any transport or parse problem it passes the original payload through unchanged: ratio is 0.0, recovery_handle is None, and basis is always "inferred". The SDK never rewrites bytes itself and never reports a saving the engine did not hand back. Because it mirrors the TypeScript SDK field-for-field, divergence between them fails CI.

Provider clients

cave.openai(), cave.anthropic(), cave.gemini(), and cave.vertex() return Provider objects proxied through the gateway. Each exposes a .raw(path, body) escape hatch for native provider paths the typed helpers don't cover. cave.bedrock(region) returns a plain dict (sdk_only=True) and makes no HTTP call.

python
openai = cave.openai(upstream_key=os.environ["OPENAI_API_KEY"])
out = openai.responses.create({"model": "gpt-4.1", "input": "hello"})

# vertex upstream_key is a Google OAuth2 access token
vertex = cave.vertex(upstream_key=gcloud_access_token())
native = vertex.raw(
"/v1/projects/p/locations/us-central1/publishers/google/models/gemini-1.5-pro:generateContent",
body,
)

You can pass a latency_class ("interactive" | "background" | "offline") to a responses.create call; anything other than "interactive" sets the x-cave-async header so the gateway can defer it.

The OTel exporter

cave.exporter(service_name=None) returns an OTelExporter bound to this client's gateway config. record_span() maps GenAI fields (provider, model, token counts, cost_usd) to gen_ai.* semantic-convention attributes; export() POSTs the buffered OTLP/JSON batch to /otlp/v1/traces and clears the buffer. No external OpenTelemetry wiring, no third-party deps — the payload is built by hand.

python
otel = cave.exporter(service_name="billing-agent")
root = otel.record_span(
"chat.completion",
provider="openai",
model="gpt-4.1",
input_tokens=1200,
output_tokens=340,
status="ok",
)
otel.record_span("tool.call", tool_name="lookup_order", parent_span_id=root.span_id)
otel.export() # → {"ok": ..., "spans_accepted": ..., "spans_total": ...}

The retry-loop breaker

cave.retry_loop_breaker(threshold=3) returns a RetryLoopBreaker that interrupts a stuck agent re-issuing the same tool call. Call record(name, args) before each invocation (or wrap it with guard()); once the identical (name, args) signature repeats past the threshold it raises RetryLoopError. Any different call resets the streak.

python
from caveman_cloud import RetryLoopError

breaker = cave.retry_loop_breaker(3)
try:
breaker.guard("search", {"q": "refund"}, lambda: run_search("refund"))
except RetryLoopError:
# the agent was looping — break out and re-plan
...

Async jobs

cave.jobs targets /api/v1/jobs on control_url and falls back to base_url. submit() returns a Job, status() re-reads it, wait() polls to a terminal state, submit_and_wait() does both, and cancel() requests cancellation. This is for gateway deployments that expose that public API path.

python
job = cave.jobs.submit_and_wait(
{"kind": "report.export", "project_id": project_id},
latency_class="background",
timeout_s=120.0,
)
print(job.state) # "completed" | "failed" | "cancelled"

See also