Metadata-Version: 2.5
Name: ciphyrs
Version: 7.2.0
Summary: Ciphyrs SDK: AI-agent observability and tool-call enforcement, built on OpenTelemetry
Project-URL: Homepage, https://www.ciphyrs.com
Project-URL: Documentation, https://www.ciphyrs.com/docs
Project-URL: Repository, https://github.com/praveen190/Ciphyrs
Project-URL: Changelog, https://github.com/praveen190/Ciphyrs/releases
Author-email: Ciphyrs <support@ciphyrs.com>
License-Expression: MIT
License-File: LICENSE
Keywords: ai-agents,ciphyrs,guardrails,observability,opentelemetry,pii
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT 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: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.25.0
Requires-Dist: opentelemetry-api>=1.20.0
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
Requires-Dist: opentelemetry-sdk>=1.20.0
Provides-Extra: auto
Requires-Dist: openinference-instrumentation-crewai>=0.1.0; (python_version >= '3.10') and extra == 'auto'
Requires-Dist: openinference-instrumentation-langchain>=0.1.0; extra == 'auto'
Requires-Dist: openinference-instrumentation-llama-index>=1.0.0; extra == 'auto'
Requires-Dist: openinference-instrumentation-openai-agents>=0.1.0; extra == 'auto'
Requires-Dist: openinference-instrumentation-openai>=0.1.0; extra == 'auto'
Provides-Extra: opentelemetry
Requires-Dist: opentelemetry-sdk>=1.20.0; extra == 'opentelemetry'
Description-Content-Type: text/markdown

# Ciphyrs — observability for AI agents

[![PyPI](https://img.shields.io/pypi/v/ciphyrs)](https://pypi.org/project/ciphyrs/)
[![Python](https://img.shields.io/pypi/pyversions/ciphyrs)](https://pypi.org/project/ciphyrs/)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)

See every agent, tool call and model call your application makes, as one
connected graph. Built on OpenTelemetry, so the spans are standard OTLP and
sit beside whatever tracing you already have — and your model calls stay
yours, made directly against your provider.

## Install

```bash
pip install ciphyrs
```

One command. OpenTelemetry and `httpx` come with it. **Python 3.9 through
3.14**, including the free-threaded 3.14 build (PEP 703).

No framework-specific install, and none exists. `@agent` and `@tool` wrap
plain **functions**, so they work with any agent framework, or none.

## Monitor your agents

```python
import ciphyrs

ciphyrs.init(api_key="cyp_live_...", project="customer-support")

@ciphyrs.tool
def issue_refund(order: str, amount: float) -> dict:
    return payments.refund(order, amount)   # traced, and checked against policy

@ciphyrs.agent("BillingAgent", role="worker")
def billing(question: str) -> str:
    answer = my_model.generate(question)      # your model call, unchanged
    issue_refund(order="A-1041", amount=12.0)
    return answer

@ciphyrs.agent("RouterAgent", role="router")
def router(message: str) -> str:
    return billing(message)      # called INSIDE router -> a RouterAgent→BillingAgent edge

with ciphyrs.session("conv-7f3a"):            # ties one conversation together
    router("I was charged twice for order A-1041")

ciphyrs.shutdown()                            # flush; short-lived scripts only
```

That is the whole integration. Nesting **is** the topology: because `billing`
was called inside `router`, the dashboard draws the edge. Both agents appear
in the fleet at start-up — decorators register themselves, before any traffic.

| | |
|---|---|
| `init()` | Configures an OTLP exporter to Ciphyrs, or attaches to a `TracerProvider` you already have and leaves your exporters alone. Falls back to `CIPHYRS_API_KEY`, `CIPHYRS_PROJECT`, `CIPHYRS_BASE_URL`. |
| `@agent` | One span per call, named for the agent. Everything it calls nests underneath. |
| `@tool` | One span per tool call, with arguments and result. Also checked against policy before it runs — `enforce` inherits `init(enforce=True)`, so a `block` verdict raises `ToolBlocked` and the function never executes. Pass `enforce=False` to trace only. |
| `session()` | Tags every span inside with `session.id`. |
| `init(agents=...)` | Declares the roster and the designed peer graph at boot, so the fleet is complete before the first request. Starts a heartbeat (default 60 s) so idle and dead are distinguishable. LangGraph, CrewAI, OpenAI Agents SDK and Google ADK objects need not be passed: see [Topology](#topology). |

Async is automatic — declare the function `async def` and the decorators
install async wrappers. Every platform call they make runs off your event
loop.

### See the inputs and outputs

On by default. Pass `capture_io=False` to an individual `@agent` or `@tool`
whose arguments must not be recorded; its spans still carry timing, nesting
and errors.

### Agents in separate processes

Already on. Outgoing HTTP requests carry the trace context, so an agent that
calls another service shows up as one connected graph rather than two
disconnected fleets. `init(propagate=False)` turns it off. Every host gets only
the opaque trace context; the agent and project *names* go only to private
hosts, the Ciphyrs hosts, and any you list with
`ciphyrs.propagation.peer_hosts([...])` — never to a model provider or other
public site.

### Topology

Automatic for **LangGraph, CrewAI, the OpenAI Agents SDK and Google ADK**.
After `init()`, the SDK notices these objects being built and declares their
designed topology — agents, roles, tool names and peer edges — before the
first request, with no code change:

| Framework | Read when | Edges from |
|---|---|---|
| LangGraph | a `StateGraph` (or `MessageGraph`) is compiled | the compiled graph's edges |
| CrewAI | a `Crew` is constructed | task order |
| OpenAI Agents SDK | an `Agent` is constructed | `handoffs` |
| Google ADK | an agent is constructed | `sub_agents` |

It works whether the framework is imported before or after `init()`, never
imports a framework itself, and never raises into the framework's code. Every
object built within about a second is read in one pass and merged by name
into one roster per process — sub-agents built before their parent, several
graphs, graphs rebuilt in tests — and a declaration is sent only when that
roster changes. Only names, roles, tool names and edges are read: never
prompts, instructions, descriptions or state. An object built *before*
`init()` is read on its first run instead: a compiled graph's `invoke` /
`ainvoke` / `stream` / `astream`, `Crew.kickoff` / `kickoff_async`, the
Agents SDK's `Runner.run` / `run_sync` / `run_streamed`, ADK's `Runner.run` /
`run_async`. Each object is read once; `ciphyrs.flush()` and `shutdown()`
declare anything still pending.

What you declare yourself wins: names, roles and peers from
`@ciphyrs.agent(..., role=, peers=)`, `init(agents=...)` or `announce()` are
never overwritten, and discovered edges are added to them. An agent that only
discovery found is declared with `source: "discovered"` and labelled
"auto-mapped" in the dashboard.

`init(auto_topology=False)` or `CIPHYRS_AUTO_TOPOLOGY=0` turns discovery off;
`ciphyrs.selfcheck()["auto_topology"]` lists the hooked frameworks and the
number of agents discovered; `shutdown()` restores the frameworks' original
methods.

#### Inferred connections for hand-written agents

Hand-written orchestration has no framework object to read, so the SDK reads
the code instead. At `init()` — and again, debounced, whenever an
`@ciphyrs.agent` is decorated after it — each agent function's compiled code
is inspected for references to *other* `@ciphyrs.agent` functions:

```python
@ciphyrs.agent("RouterAgent")
def router(q):
    return fraud(q) if "fraud" in q else billing(q)   # RouterAgent → FraudAgent, BillingAgent
```

Nothing is executed and nothing is imported. Caught: direct calls in any
branch; `module.agent()` and `Class.agent()`; an agent captured in a closure;
calls inside nested functions, lambdas and comprehensions; `self.other()` on
the agent's own class (and its bases); a module-level dispatch table — a dict,
list, tuple or set of at most 200 items holding agents, e.g.
`ROUTES = {"b": billing}; ROUTES[k](q)`. Not caught: anything decided at run
time — `getattr(mod, name)`, `globals()[name]`, a name built from a string, an
agent passed in as an argument, a call made through a plain helper function —
and tables nested more than one level deep. Those edges appear when traffic
crosses them.

Inferred edges are sent as each agent's `inferred_peers` (agent names only,
never source or constants), not as `peers`, and a name you already gave in
`peers=` is not repeated. The dashboard draws them dashed until traffic
confirms them. `init(infer_topology=False)` or `CIPHYRS_INFER_TOPOLOGY=0`
turns it off; `ciphyrs.selfcheck()["infer_topology"]` counts the agents
inspected and the edges inferred, and lists any agent that could not be
inspected (a C or Cython function, for one) with the reason.

### Model and framework internals

Spans *inside* a framework or provider — retriever calls, per-node detail —
come from that framework's own OpenTelemetry instrumentation, not from
Ciphyrs. `init()` finds the agent frameworks installed in the process
(CrewAI, LangGraph, LangChain, the OpenAI Agents SDK, Google ADK, LlamaIndex,
AutoGen, the OpenAI client) and activates the matching OpenInference
instrumentor when it is installed; `pip install "ciphyrs[auto]"` installs
them (AutoGen's goes beside AutoGen), a missing one is named once in the log,
and `init(auto_instrument=False)` turns this off. Their spans travel through
the exporter `init()` already installed and nest inside your agent spans.
(Ciphyrs does sit on the model request itself, to mask and guard it: see the
model-call shield below.)

### Is it actually on?

```python
state = ciphyrs.selfcheck()
if state["problems"]:
    log.error("Ciphyrs is not live in this process: %s", state["problems"])
```

Reports what is genuinely running and names each problem in words. Makes no
network call, so it is safe in a readiness probe.

## Beyond tracing

The platform also enforces policy on agent messages, masks PII, and can
quarantine a misbehaving agent from the dashboard. **Tool checks, message
checks and PII masking are all on by default** once `init()` has an API key,
which means text leaves your process to be evaluated. The documentation says
what each one sends and how to turn it off (`enforce=`, `guard_input=`,
`guard_output=`, `pii=`).

**Every one of them fails closed by default** (since 6.0): if Ciphyrs cannot
give an answer, the input is refused (`InputBlocked`), the reply is refused
(`OutputBlocked`), the tool does not run (`ToolBlocked`) and text that could
not be masked does not reach the model (`PIIProtectionError`). An enforced
`@tool` with no API key is refused too. Each stage can be opened per
application -- `guard_fail_closed={"input": False}`, `pii_fail_closed=False`,
`@tool(fail_closed=False)`, `@protect_tool(..., fail_closed=False)` -- and is
logged once when it is. An opened stage proceeds only during a genuine outage
(no connection, a 5xx without a verdict). A gateway refusal (a
`fail_closed: true` body, at any status) is a block under both postures, and a
timeout, a 429 or any other 4xx is refused as well. Tool arguments too large
to send whole (over 8,000 characters in one value or 64,000 in all) reach the
gate clipped, flagged `args_clipped` with the `args_sha256` of the full
arguments, and the call is refused unless the gateway acknowledges the clip
(or the tool was opened with `fail_closed=False`).

**Model calls are masked and guarded wherever they are made** (since 7.0).
`init()` patches `httpx` (sync and async) and `requests` — what the OpenAI,
Anthropic, Mistral, Cohere and Google GenAI Python SDKs use — so a POST, PUT or
PATCH to a model provider (OpenAI, Azure OpenAI / AI Foundry, Anthropic,
Gemini / Vertex AI, Bedrock through an httpx client such as `AnthropicBedrock`,
Mistral, Cohere, Groq, Together, DeepSeek, xAI, OpenRouter, Fireworks,
Perplexity) has every string at a prompt position
(message content, system prompts, tool-call arguments and tool outputs,
embeddings input) masked in one batch before it leaves the process, whether or
not the call is inside `@agent`. That is what covers a framework that owns the
loop — LangGraph, CrewAI, the OpenAI Agents SDK. Outside an `@agent` turn, and
inside a `@tool` body (whose arguments hold real values), the shield also runs
the input guard before the request is sent and the output guard on the reply,
and restores the real values in the reply, tool-call arguments included; a
streamed reply is restored as it arrives and, with the output guard on,
released only once the guard has ruled on it. Inside an `@agent` turn the
agent does the guarding and restoring, and the shield masks only what is
still raw, in the turn's vault session. Embeddings requests are masked too, so
their vectors are computed over the placeholder text.

What the shield does not cover: clients that use neither httpx nor `requests`
— `boto3` / botocore for Bedrock, `aiohttp`, gRPC clients — whose calls need an
`@agent` boundary; images, audio and files inside a request, which are sent as
they are (only text is masked); and audio or other binary replies, which are
returned without a guard. A request body that is not JSON (a file upload, a
form, a transcription) and a request for a stream that is not server-sent
events (Bedrock's event stream, NDJSON) are refused rather than sent unmasked,
and so is a body (or a reply that needs restoring) over 64 MiB.
A refusal comes back as an HTTP response, not an exception, so the provider's
SDK raises its own error and does not retry it: 400 when the body cannot be
masked, 403 when a guard blocked the prompt or the reply, 503 when Ciphyrs
could not mask or guard under the closed posture (the SDK retries that one),
each with an `x-ciphyrs-shield` header and a body saying "Blocked by
Ciphyrs". Add a self-hosted model or an internal LLM gateway with
`init(shield_hosts=[...])` or `CIPHYRS_SHIELD_HOSTS`; exempt an endpoint with
`init(shield_exclude=["api.openai.com/v1/files"])` or `CIPHYRS_SHIELD_EXCLUDE`
(its bodies are then sent unmasked, logged once); `init(shield=False)` turns
the shield off, which is logged and reported by `selfcheck()` as a problem
while masking is on. The shield is on whenever `init()` has an API key and
`pii` is not `"off"`.

**Log forwarding is off by default** (since 6.0). `init(capture_logging=True)`
sends every record your application logs at `log_level` or above (INFO by
default, through Python's `logging`, from any logger) to Ciphyrs, shown on the
trace. Credentials are redacted locally before a line leaves the process; the
SDK does not run log lines through the masking service, and the Ciphyrs
gateway masks forwarded lines on ingest before they are stored.

Spans carry `enduser.id` as `sha256:<hex>` of the id you pass to
`ciphyrs.session(..., end_user=)` (raw only with `pii_capture_raw=True`). The
PII vault session id is written only on the spans exported to Ciphyrs, not on
the span your own exporters see: within its workspace it works like a bearer
reference to the turn's real values. `init(export_session_id=True)` writes it
on the span itself.

Governed tools present a verified agent identity: a short-lived token is
minted for the agent's name on its first governed call and refreshed before
it expires. No fleet declaration is needed. If the gateway refuses the token,
the log says why and how to fix it, and the project's identity mode decides
whether the tool runs. If the gateway cannot be reached, the tool is refused
unless it was opened with `fail_closed=False`.

`@agent` closes each turn's PII vault session when the turn ends. For a
session nothing else will close (one you masked into with `CiphyrsClient`,
or a turn run without `@agent`), call `ciphyrs.close_session(session_id)`,
or `await ciphyrs.aclose_session(...)` from async code. Closing twice is
safe, and the call never raises. It returns a `SessionCloseResult`, truthy
only when the gateway confirmed the close; `retained_hours` is set when your
workspace keeps a review copy. `CiphyrsClient.protect()` closes its session on
every path unless you pass `purge=False`, and reports `session_closed`.

- [Documentation](https://www.ciphyrs.com/docs)
- [Website](https://www.ciphyrs.com)
- [Dashboard](https://www.ciphyrs.com/dashboard)

_Third-party names are trademarks of their respective owners; no affiliation or endorsement is implied._
