Metadata-Version: 2.5
Name: thulpar
Version: 0.4.0
Summary: Thulpar client for Python: the check API 0.3.x, sentence-level stream guard, decisions applied to text, hold and resume, trajectories, canaries and counters; FastAPI, OpenAI, NeMo Guardrails, Guardrails AI and LangChain integrations as extras
Project-URL: Homepage, https://thulpar.com
Author: Abra Partners
License: Copyright (c) 2026 Abra Partners. All rights reserved.
        
        Use of this package is subject to the Thulpar licence agreement.
License-File: LICENSE
Keywords: ai,guardrails,llm,security,thulpar
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: h11<1,>=0.14
Requires-Dist: httpx<1,>=0.27
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
Provides-Extra: guardrails
Requires-Dist: guardrails-ai<1,>=0.6; extra == 'guardrails'
Provides-Extra: langchain
Requires-Dist: langchain-core<2,>=0.3; extra == 'langchain'
Provides-Extra: nemo
Requires-Dist: nemoguardrails<1,>=0.20; extra == 'nemo'
Provides-Extra: openai
Requires-Dist: openai<4,>=1.40; extra == 'openai'
Description-Content-Type: text/markdown

# thulpar — Python client for Thulpar

The Python SDK for [Thulpar](https://thulpar.com), the on-prem security and guardrails
layer for AI agents and LLM applications. It speaks the **check API 0.3.x**
(`thulpar` 0.4 adds the framework integrations; the contract is still 0.3.x) and does on the client side what every integration
would otherwise write itself: time budgets, the circuit breaker, applying a decision to
text, releasing a streamed answer sentence by sentence, holding for an approval and
resuming, normalising agent trajectories, canary tokens and the budget-rail counters.

The package does nothing without a Thulpar URL and an API key. Runtime dependencies:
`httpx` (and `h11`, its own HTTP/1.1 parser, for the stream). Python 3.11+. Sync and
async clients with the same surface. It never logs and never raises on the check path.

```bash
pip install thulpar
pip install "thulpar[openai]"      # or [fastapi], [nemo], [guardrails], [langchain]: the integrations
```

## Quick start

```python
from thulpar import Thulpar, apply_decision

t = Thulpar(
    url="https://thulpar.example.com",
    api_key="thp_live_…",
    tenant_id="tnt_acme",
    app_id="support-bot",
    mode="enforce",          # or "shadow": checks, records, never blocks
)

d = t.check("input", user_text, context={"hook": "pre_call", "conversationId": conv_id})
if d.blocks:                 # deny, hold, reask (or a closed posture when Thulpar was down)
    reply(d.reason)
else:
    text = apply_decision(d, user_text)   # redactions and rewrites applied

# A streamed answer, released one cleared sentence at a time
guarded = t.guard_stream(model_stream, context={"hook": "stream"}, messages=conversation)
for piece in guarded:
    send_to_client(piece)
result = guarded.result      # decision, ledger id, the text of record, a hold's approval
```

```python
from thulpar import AsyncThulpar

async with AsyncThulpar(url=..., api_key=..., tenant_id=..., app_id=...) as t:
    d = await t.check("tool_call", {"toolCall": call}, trajectory=trajectory.from_openai(messages, tools))
    async for piece in t.guard_stream(model_stream):
        ...
```

## What a `Decision` tells you

`check()` returns a frozen `Decision`. `decision` is always one you can act on:

| | |
|---|---|
| `passes` | `allow`, `allow_with_findings`, `escalate` — the content goes on as it is |
| `rewrites` | `redact`, `rewrite` — the content goes on changed; `apply_decision(d, text)` gives it (`d.text` is Thulpar's own `content.text`) |
| `blocks` | `deny`, `hold`, `reask` — nothing goes on. A caller that does not re-ask treats `reask` as a deny; one that does reads `d.reask.errors` |
| `held` / `approval` | a `hold` opened an approval. The `resumeToken` is dropped by default: resume by re-sending the request once approved. With `resume_tokens="keep"` it is delivered **once** as `d.resume_token` and kept nowhere else |
| `pending` | a 202: no decision yet (a speculative ticket, an audit job). Not passing, not Thulpar's success or failure; `t.ticket(d.ticket)` reads the result |
| `ok` / `source` | `ok` when Thulpar answered (`source == "check"`); otherwise `source` is the failure and `decision` is the posture the client applied |
| `enforced_decision` | shadow mode: what enforce would have decided (`decision` never blocks in shadow) |
| `findings`, `categories`, `actions`, `ledger_id`, `rails_skipped`, `fail_closed`, `raw` | the rest of the answer |

Every failure comes back as a decision too — nothing on the check path raises:

| failure | what it means | enforce decision |
|---|---|---|
| `deadline`, `network`, `http` (5xx), `malformed` | Thulpar could not be reached or answer | the stage's fail posture (`input`, `tool_call`, `file` closed; the rest open); counts on the breaker |
| `rate_limited` (429) | this key's rate limit, that request only | the stage's posture; the breaker is not told (a held probe is released) |
| `breaker_open` | the breaker refused the call | the stage's posture |
| `config` (400/401/402/403), `unconfigured` (no URL / no tenant), `unknown_decision`, `malformed_request` (a body this side could not build: a value JSON cannot write, a lone surrogate, a URL httpx refuses) | this integration is misconfigured | `on_config_error`: `closed` (default), `open`, or `posture` |
| `too_large` (413) | the content is over Thulpar's limit | deny |
| `off` | `mode="off"`: nothing was sent | allow |

In shadow mode a failure is always `allow`. `health()` shows the mode, the breaker state,
the last configuration error (until a call succeeds again) and counters per outcome.

The configured `tenant_id` / `app_id` win; a caller's `context.tenantId` / `appId` are the
fallback; a check with neither is `unconfigured`. The API key is required when the client
is built (a client may start with `mode="off"` and no key, and be configured later — the
Aginies pattern).

## Deadlines and the breaker

Two numbers per stage. The budget sent as `options.deadlineMs` is Thulpar's to keep — it
cuts its rails there and applies each rail's posture — and defaults to Thulpar's built-in
ceilings (input / tool calls 1000 ms, output 1500 ms, …). The HTTP call is abandoned at
budget + `network_allowance_ms` (250), so a verdict Thulpar reaches at its deadline — its
own fail-closed deny included — is received, not replaced by the posture. Deadlines are
ceilings, not targets.

One circuit breaker per Thulpar URL, shared by every client in the process: after
`Breaker(failures=5)` consecutive failures it opens for `open_seconds=30` (or a 503's
`Retry-After`, whichever is longer), then admits exactly one unary probe. Deadlines,
network errors, malformed bodies and 5xx count; 429, configuration errors, 413, a request
this side could not build, a stalled or failed source and an abandoned stream do not.

## Streams

`guard_stream(chunks, ...)` feeds a streamed answer to `/v1/check/stream` (stage
`output_chunk`) and yields what may be shown. In **enforce** a sentence is released once Thulpar has answered for it:
`allow` / `allow_with_findings` / `escalate` release it, `redact` releases its
`redactedText` instead, and `deny` (with its retract), `hold`, `rewrite`, `reask` or a
`redact` without the text stop the answer there — the source is no longer pulled and the
stream's end frame is still read for the ledger id. The whole-output pass at the end can
refuse an answer already released (`result.retracted_at == 0`), or redact / rewrite the
record: the released text stands, `result.record_text` is the text with the pass's actions
applied and `result.modified` is true — **store `record_text`, never the raw answer.**
`prepend_frames=True` opts into `{"prepend"}` frames, released before the sentence they
precede. A session that fails mid-way follows the `output_chunk` posture (open by default):
the rest is released unchecked. In **shadow** everything is released as it arrives and
`result.enforced_decision` says what enforce would have done; since 0.3.1 the iteration ends
with the source — the consumer never waits for Thulpar's end frame — and `result` is set when
the verdict arrives (`stream.wait_result(timeout=30)`, `await stream.wait_result()`;
`shadow_wait=True` keeps the 0.3.0 behaviour). `wait_result()` on a stream nobody iterated
drains it first (its text is read and dropped). A verdict still pending when the event loop goes
away is recorded as `failure == "cancelled"`, never as a clean pass.

**Shadow never waits for the connection (0.3.2).** In shadow the first piece is released as it
comes from the source, before the `/v1/check/stream` connection is open: the connection (DNS,
TCP, TLS, a proxy's `CONNECT`) opens beside the release, what the source gave meanwhile is sent
once it is open, and the verdict is waited for in the background (the end wait runs from the
open deadline, so a connection that opened late still delivers it). A connection that never
opens is the result's failure (`deadline` / `network`, `result.ok` false). Enforce, and shadow
with `shadow_wait=True` (which waits for the verdict anyway), open the connection first, as
before.

**Behaviour change (0.3.1):** the iteration yields one `StreamPiece` per release — 0.3.0 joined
what was released between two wake-ups into one string — so a consumer sees each sentence (and
each redaction) as its own piece; `"".join(stream)` is unchanged.

Each piece is a `StreamPiece` — a `str`, so joining and printing are unchanged — that says where
it stands in the raw answer: `from_` / `to` (Python indices into the text read from the source;
`raw[p.from_:p.to] == p`), `sentence` (True when Thulpar cleared it; False when released
unchecked — shadow, a fail-open posture, a prepend), and `redacted` (the sentence it replaces
is `raw[p.raw_from:p.raw_to]`). A host that speaks or rebuilds the answer maps released text by
offset, never by searching for it or guessing a sentence end from punctuation.

Opening a stream (DNS, TCP, TLS, a proxy's CONNECT, the response head) is bounded by
`stream_open_ms` (5000) + the allowance; the end frame by `stream_end_ms` + the sentence
budget + the allowance. `sentence_stall_ms` (off by default) fails the session as `stall`
when a complete sentence has waited that long since the previous verdict — the posture
applies, the breaker is not told (`count_stalls_on_breaker=True` counts it like a deadline).

`result` (a `StreamResult`) is set once the stream is over: `decision`, `blocked`,
`retracted_at`, `released_text`, `record_text`, `modified`, `sentences`, `approval`,
`ledger_id`, `failure`, and since 0.3.1 `first_verdict_ms` and `sentence_latencies_ms` (per
verdict, the time after the sentence's text had arrived). `close()` / `aclose()` abandons a stream; that is never counted as
a failure. A source that raises ends the stream without an end frame (Thulpar records it
as aborted, `result.failure == "source_error"`) and the error is raised to the consumer.
The sync client iterates the source from a worker thread (under a copy of the caller's
context variables) so a cleared sentence reaches the consumer without waiting for the
model's next token; `close()` asks the source to close, and a source that blocks forever
keeps its thread. The async client pulls a plain (sync) iterable in a worker thread too, so
the event loop never blocks on it.

## Holds, approvals, resume

```python
d = t.check("tool_call", {"toolCall": call})
if d.held:
    pause(run, approval_id=d.approval.id)                      # your storage, not the SDK's

approval = t.approvals.wait(approval_id, timeout=30)           # long poll; pending at the timeout
if approval.effective_outcome != "deny":
    d = t.check("tool_call", {"toolCall": call})               # the same request: Thulpar lets it through once
```

With `resume_tokens="keep"` the hold's `d.resume_token` is delivered once and
`t.resume(approval_id, token)` answers the final decision (`allow` /
`allow_with_findings` / `deny`) without re-sending. `approvals.get`, `approvals.wait`, `resume` and `ticket` are control
calls: they raise `ThulparError` (`NotFound` for an unknown approval or a wrong token).

## Trajectories, canaries, counters, files

```python
from thulpar import trajectory, canary, counters

traj = trajectory.from_openai(messages, tools)       # or from_anthropic(messages, tools, system) / from_gemini(contents, tools, system_instruction)
d = t.check("tool_call", {"toolCall": call}, trajectory=traj)   # bounded to the contract's limits for you

token = canary.make()                                 # 24 characters; plant it, name it in context.canaries
system_prompt = canary.plant(system_prompt, token)
d = t.check("output", answer, context={"canaries": [token], "firstTurn": True})

runs = counters.RunCounters()
runs.open(run_id, prior_tokens=counters.conversation_tokens(earlier_usages))
before, release = runs.reserve_tool_call(run_id)
d = t.check("tool_call", {"toolCall": call}, context=runs.context(run_id))

f = t.check_file(data, name="policy.pdf")             # the file stage; f.clean as a scan hook reads it
```

`trajectory.from_responses(input, tools, instructions)` reads OpenAI Responses items
(`from_openai` reads them too). `check` masks the trajectory it sends (0.3.1) before bounding it
— a clipped JSON body would hide its rows: every earlier call's arguments as
`tool_arguments.mask` masks them, every message's text (tool results first of all) the same way,
what the budget does not reach replaced by a marker — `trajectory_mask=False` sends it as it is;
`trajectory.mask(t)` or `mask=True` on the readers does it yourself.

## Tool arguments

```python
from thulpar import tool_arguments

sent = tool_arguments.mask(params, omit=FRAMEWORK_PARAMS)    # credentials as "[redacted]"
d = t.check("tool_call", {"toolCall": {"name": name, "arguments": sent}})
run_with = tool_arguments.try_merge(params, sent, d.content["toolCall"]["arguments"])  # None: unappliable
```

`mask` hides every value under a credential's name at any depth; header rows
(`{"cells": {"Key", "Value"}}`, `{"key" | "name" | "header", "value" | "val"}`), header tuples and argv
lists (`["psql", "--password", S]`, `["mysql", "-pS"]`); URLs of any scheme (userinfo, query or
fragment credentials); a JSON body held as a string; and free text such as a shell `command`
(`Authorization: Bearer …`, `password = …`, `--password …` with `\` continuations,
`-u user:pass`, `docker login -p …`, `echo S | … --password-stdin`, a URL-encoded URL inside a
parameter, PEM private keys, `sk-…` / `ghp_…` / `xoxb-…` / `AKIA…` /
`AIza…` / JWTs). Scanning is linear (URLs parsed by hand), reads 64 KiB of a string, and one call has a budget
(1 MiB, 40 ms): a string it did not read whole is sent as it is and a change to it is not merged.
Nesting past 64 levels is replaced by a marker.

`merge` puts a credential back in three cases only: **free text** (a shell `command`, a message)
that holds a mask, when Thulpar returned that text exactly as it was sent; a value that is **one
URL**, when its origin, path and parameter names are unchanged; a **row or key** under its own
name. Any change to free text that holds a mask — an added flag, a moved header, a new host —
raises `UnappliableArgumentsError`, as do a new field holding the mask and a credential beside a
URL whose origin or path changed. `try_merge`
answers `None` and never raises: the call must not run with a mask in it.

In a list of rows a row's name is structure, not a secret (0.3.2): for the items of a list
shaped `{"cells": {"Key", "Value"}}`, `{"key", "value"}`, `{"name", "value"}`, `{"name", "val"}`,
`{"header", "value"}` or `[name, value]`, the name field is never masked because of its own field
name (`Key` and `key` are credential names elsewhere) — it is scanned as text; the value is
masked when the row's name is a credential's — `[{"cells": {"Key": "Accept", "Value":
"application/json"}}]` goes as it is. A standalone object is read by its field names
(`{"license": {"key": "…", "value": "enabled"}}` masks `key`); with both `name` and `key`,
`name` is the row name and `key` a credential. A credential row's name is scanned as text too
(`{"key": "api_key=sk-live-…"}`). With `scan=False` masking stays by field name only.

## Content that could not be sent

`t.unchecked(stage, reason, mode=None)` answers the stage's posture for content the host could
not send at all (`too_large`, `incomplete`, `unreadable`, or a reason of its own): `deny` where
the stage is closed in enforce, `allow` where it is open, in shadow and off; `unconfigured`
follows `on_config_error`. `mode="enforce"` answers as enforce while the client is off.

## Framework integrations (0.4.0)

Optional submodules, each with its extra; the core never imports them. They are thin: every
check is the core client's `check()` / `guard_stream()`, and what a decision means is the
core's. Common rules: **enforce** refuses what it cannot read or cannot change as the decision
asks (`thulpar_unreadable`, `thulpar_unappliable`) and follows the stage's posture when Thulpar
cannot be reached; **shadow** never blocks and never delays — the work goes on as it came and
the check runs beside it (a thread or a task, at most 64 at once; `on_decision(stage,
decision)` sees the verdicts); **off** sends nothing. Credentials in tool arguments are masked
(`tool_arguments.mask`) before they are sent. A refusal is a `ThulparBlocked` (`kind`:
`thulpar_blocked` / `thulpar_hold` / `thulpar_unavailable`, `code`, `reason`, `ledger_id`,
`approval`, `status` 403 or 409, `error()` — the body Thulpar's gateway writes).

| Extra | Module | What | Tested with |
|---|---|---|---|
| `fastapi` | `thulpar.fastapi` | ASGI middleware, a dependency, a guarded `StreamingResponse` | FastAPI 0.143.3 / Starlette 1.9.0 |
| `openai` | `thulpar.openai` | `httpx` transport under the OpenAI client: requests, JSON and SSE answers | openai 2.54.0 (and 3.33.0) |
| `nemo` | `thulpar.nemo` | NeMo Guardrails action for input / output / retrieval / tool rails | nemoguardrails 0.24.1 |
| `guardrails` | `thulpar.guardrails` | Guardrails AI validator `thulpar/check` | guardrails-ai 0.11.0 |
| `langchain` | `thulpar.langchain` | callback handler, guarded runnable, guarded tool | langchain-core 1.6.10 |

### OpenAI (`thulpar[openai]`)

```python
from openai import OpenAI
from thulpar import Thulpar
from thulpar.openai import http_client

t = Thulpar(url=..., api_key=..., tenant_id=..., app_id=..., mode="enforce")
client = OpenAI(http_client=http_client(t, context={"conversationId": conv_id}))
client.chat.completions.create(model="gpt-4.1", messages=messages, stream=True)   # unchanged
```

`ThulparTransport` (sync, a `Thulpar`) / `AsyncThulparTransport` (`AsyncOpenAI`, an
`AsyncThulpar`) wrap the transport that reaches OpenAI (`transport=`, default
`httpx.HTTPTransport()`; put your proxy there — a client given its own transport does not read
the environment's proxies). `http_client()` / `async_http_client()` build the `httpx` client
with the OpenAI client's timeout and limits and **without following redirects** (a redirect
would re-send the request the client built, not the one checked and changed here; on a guarded
route enforce refuses any 3xx, `thulpar_redirect`, whatever the client's setting). For Chat Completions (`…/chat/completions`, legacy
`…/completions`) and Responses (`…/responses`, and `GET …/responses/{id}`):

- **Request**: one `input` check of the conversation (one message per text part; every tool
  output as a `tool` message — Responses `function_call_output`, `custom_tool_call_output`,
  `shell_call_output`, `local_shell_call_output`, `apply_patch_call_output`, an `mcp_call`'s
  output, `mcp_list_tools` descriptions, `file_search_call` results, code interpreter logs); a
  redaction is written into the exact part it came from; image, audio and file parts, an item
  type this reader does not know (`computer_call_output`, …) and a token-ID prompt follow the
  `input` posture (`Thulpar.unchecked`, closed by default); a refusal
  answers **403** `thulpar_blocked` / **409** `thulpar_hold` without calling the model, with
  `x-should-retry: false` (the OpenAI client raises `PermissionDeniedError` / `ConflictError`
  and does not retry).
- **JSON answer**: each choice's texts at `output` (with the request's system prompt, for
  `system_prompt.leak`); a redaction of the answer's own text is written back and
  log-probabilities are stripped; a change to reasoning or a refusal text cannot be written
  (refused); each proposed tool call at `tool_call` (with the trajectory read from the
  request) — a change to a proposal's arguments cannot be applied (refused). Answers with data
  outside the grammar (audio bytes, an unknown output item, a `custom` tool call, arguments that
  are not a string) are refused.
- **SSE answer**: `thulpar._providers`, a port of `@thulpar/sdk/providers` for `openai-chat` and
  `openai-responses`: one `guard_stream` session per choice; the client receives exactly what
  Thulpar released, in OpenAI's own frames (cut inside a frame, a redaction in the content it
  replaces, `…done` / `response.completed` rebuilt from what was released, log-probabilities
  removed from every changed frame and every frame after a redaction or a cut); the finish and
  usage frames wait for the verdict; tool-call frames are held until each assembled call has
  its `tool_call` verdict (a call without a name is refused). **A streamed call the clients
  would assemble differently is refused** (`thulpar_unreadable`): a fragment without an `index`,
  an `index` that is not a non-negative integer or not the call's position in order of
  appearance, a second `name` fragment (openai-python concatenates names and places calls by
  position; openai-node and the guard replace and key by index), a `custom` call or arguments
  that are not a string. Nothing goes out before the
  stream shows its format; a frame outside the grammar (another format, an unknown field or
  event, audio, a frame over 1 MiB, JSON nested past 64) refuses the answer. A refusal is one
  in-band error frame (the OpenAI client raises `APIError` with the refusal as `body`).
  Shadow forwards every frame as it came. One answer is read for 16 Mi characters of text
  (past it: refused in enforce, unchecked in shadow). The OpenAI client closes the response at
  `[DONE]`: a close after the terminal frame is the end of the answer, not an abort — shadow's
  verdict and the reports still arrive. `on_decision(stage, decision)` receives each streamed
  call's decision, and `on_stream(result)` the stream's `ProviderGuardResult`, on the stream's
  own thread or task once it is over (never in front of the client).

Other routes (embeddings, files, models, stored-completion listings) pass through unchanged.
Not ported: the Anthropic and Gemini grammars, and `onToolDeny: 'replace'` (a refused call
stops the answer).

### FastAPI / Starlette (`thulpar[fastapi]`)

```python
from fastapi import Depends, FastAPI
from thulpar import AsyncThulpar
from thulpar.fastapi import CheckedText, InputGuard, ThulparMiddleware, guarded_streaming_response

t = AsyncThulpar(url=..., api_key=..., tenant_id=..., app_id=..., mode="enforce")
app = FastAPI()
app.add_middleware(ThulparMiddleware, thulpar=t, paths=["/chat"], check_response=True)

guard = Depends(InputGuard(t))                       # or per route

@app.post("/ask")
async def ask(checked: CheckedText = guard):
    return guarded_streaming_response(t, model_stream(checked.text), media_type="text/event-stream")
```

- `ThulparMiddleware` (pure ASGI, an `AsyncThulpar`): on the `paths` (prefixes or a callable;
  default every route) and `methods` (POST, PUT, PATCH), a JSON body's string values (or a text
  body) are checked at `input` as messages; a redaction is written back into the body the app
  reads; a refusal answers 403 / 409 with `{"error": {...}}` before the app runs. A body over
  `max_body_bytes` (1 MiB), malformed JSON or JSON nested past 64 is refused in enforce; other
  content types follow the `input` posture. `check_response=True` also checks the app's answer:
  JSON at `output` (redactions written back, `content-length` fixed), `text/plain` through
  `guard_stream` (released sentence by sentence; a refusal ends it), and `text/event-stream`
  **with its framing kept**: every event goes out as the app wrote it (`event:` / `id:` lines,
  comments, `[DONE]`, one payload per event) once its text is released. OpenAI Chat Completions
  and Responses frames are read by their own grammar (as `thulpar.openai`); plain `data:` text
  is redacted in place; other JSON payloads are read by `sse_text=lambda payload: …` (their text,
  `""` for none, `None` when it cannot say), by default Anthropic Messages events. An event the
  relay cannot read, or a redaction of a JSON payload (which it cannot rewrite), refuses the
  answer in enforce (an `event: error`); shadow passes the stream byte for byte and checks
  beside it. `on_stream(result)` receives an event stream's result. Other response types and
  non-2xx answers pass as they are.
- `InputGuard(t, extract=..., stage="input")`: a dependency that checks one text — by default a
  JSON body's `input` / `message` / `prompt` / `query` / `question` / `text` / `content` — and
  hands the route `CheckedText(text, decision)` with redactions applied, or raises
  `HTTPException` 403 / 409 (`detail` is the refusal).
- `guarded_streaming_response(t, chunks, media_type=..., on_result=...)`: a `StreamingResponse`
  over `guard_stream`.

### NeMo Guardrails (`thulpar[nemo]`)

```python
from nemoguardrails import LLMRails, RailsConfig
from thulpar import AsyncThulpar
from thulpar.nemo import register

rails = LLMRails(RailsConfig.from_path("./config"))
register(rails, AsyncThulpar(url=..., api_key=..., tenant_id=..., app_id=..., mode="enforce"))
```

`config/config.yml`:

```yaml
rails:
  input:
    flows: [thulpar check input]
  output:
    flows: [thulpar check output]
  retrieval:
    flows: [thulpar check retrieval]
  tool_output:              # tool calls, before they run
    flows: [thulpar check tool calls]
  tool_input:               # tool results, before the model reads them
    flows: [thulpar check tool results]
```

`config/thulpar.co` (Colang 1.0; `thulpar.nemo.COLANG` holds all five):

```
define subflow thulpar check input
  $allowed = execute thulpar_check(stage="input")
  if not $allowed
    bot refuse to respond
    stop

define subflow thulpar check retrieval
  $allowed = execute thulpar_check(stage="retrieval")
  if not $allowed
    $relevant_chunks = ""
```

The action `thulpar_check(stage=...)` reads the rail's own variable (`$user_message`,
`$bot_message`, `$relevant_chunks`, each of `$tool_calls`, `$tool_message`), returns `True` when
it may go on, writes a redaction back into `$user_message` / `$bot_message` /
`$relevant_chunks` (a change to a tool call or a tool result cannot be written back: `False`),
and sets `$thulpar_decision`, `$thulpar_reason` and `$thulpar_code`. Shadow returns `True` at
once. The client and the checks' context are action parameters (`register` sets them); with a
config folder's `actions.py`, `rails.register_action_param("thulpar", t)` is enough. On an
on-prem install set `NEMO_GUARDRAILS_NO_USAGE_STATS=1`: NeMo Guardrails reports usage
statistics to NVIDIA by default.

### Guardrails AI (`thulpar[guardrails]`)

```python
from guardrails import Guard
from thulpar.guardrails import ThulparValidator

guard = Guard().use(ThulparValidator(thulpar=t, stage="output", on_fail="fix"))
outcome = guard.validate(answer, metadata={"thulpar_context": {"conversationId": conv_id}})
```

`ThulparValidator` (registered as `thulpar/check`, strings) maps `allow` /
`allow_with_findings` / `escalate` to `PassResult`; `redact` / `rewrite` to a `FailResult` whose
`fix_value` is the text after the decision, with an `ErrorSpan` per redacted span (`on_fail="fix"`
passes it on); `deny` / `hold` / `reask` (and, in enforce, a closed posture or a change that
cannot be applied) to a `FailResult` with no fix and the refusal in `metadata["thulpar"]` — use
`on_fail="exception"` (or `"refrain"` / `"filter"`) for those. A `Guard` needs a sync
`Thulpar`; an `AsyncGuard` takes either. On an on-prem install turn Guardrails AI's own
metrics off (`guardrails configure --disable-metrics`, or `enable_metrics=false` in
`~/.guardrailsrc`): they are on by default.

### LangChain (`thulpar[langchain]`)

```python
from thulpar.langchain import ThulparCallbackHandler, guard_runnable, guard_tool

llm = guard_runnable(ChatOpenAI(model="gpt-4.1"), t)       # input / output / proposed calls
tools = [guard_tool(search, t), guard_tool(send_mail, t)]  # tool_call before, tool_result after
agent.invoke(x, config={"callbacks": [ThulparCallbackHandler(t)]})
```

- `guard_runnable(runnable, t)`: the prompt (a string, a prompt value or messages) is checked at
  `input` and a redaction applied to what the model receives; the answer at `output` and a
  redaction applied to the `AIMessage` the chain receives; each proposed tool call at
  `tool_call` (a change to a proposal cannot be applied: refused). Content lists are read block
  by block: every `text` block is checked (and a redaction written into its own block),
  `thinking` / `reasoning` blocks are checked but never rewritten (a change to one is refused),
  `tool_use` blocks are checked at `tool_call` with the message's `tool_calls`; a block with
  data that is not text (an image) is refused in enforce.
- `guard_tool(tool, t)`: the same tool (name, description, schema) with its masked arguments
  checked at `tool_call` before it runs — a rewrite is merged back
  (`tool_arguments.try_merge`) or refused — and its result at `tool_result`.
- `ThulparCallbackHandler` / `AsyncThulparCallbackHandler`: every chat model, LLM, tool and
  retriever run of a chain or agent (`stages=` limits them). A callback cannot change what it
  observes: in enforce a refusal, or a change, raises `ThulparBlocked` (`raise_error` is on) and
  stops the run — use the wrappers where redactions should be applied. Content blocks are read
  as by the wrappers: every text block is checked, `tool_use` blocks at `tool_call`, and what
  cannot be read is refused in enforce (shadow checks what it can).

A sync `Thulpar` serves the sync and the async paths (the async ones run it in a worker thread);
an `AsyncThulpar` serves the async paths only.

## Aginies products

An Aginies product does not configure Thulpar itself: the Platform serves the admin
panel's state to its satellites and a change applies within 30 seconds.

```python
from thulpar import Thulpar, aginies

runtime = aginies.RuntimeConfigClient(platform_url, satellite_key)   # GET /api/internal/thulpar/runtime-config, cached 30 s
t = Thulpar(tenant_id=tenant_id, app_id=app_id, surface="voice")
runtime.apply_to(t)                                                   # mode (off / shadow / enforce), URL, key, outage posture
```

0.3.1 (**behaviour change**): `parse_runtime_config` no longer raises for a setting enabled
without a usable `checkUrl` — it is applied as it is, and every check is then `unconfigured`.
The panel's outage posture is applied (`outage_posture="closed"` closes every stage
when a check cannot run; the host's own `fail` stays underneath for `standard`). A setting
enabled without a key or a check URL no longer raises: the client's checks are configuration
errors, closed in enforce. `check_url_override` replaces the Platform's `checkUrl` where this
host reaches the check API another way. `until_first_read="closed"` answers every check as a
configuration error (denied) until the Platform was read once; the default `"off"` checks
nothing until then — a cold start while the Platform is unreachable passes traffic unchecked,
`"closed"` refuses everything instead.

## Configuration

`Thulpar(url, api_key, tenant_id, app_id, *, mode, deadlines, breaker, on_config_error,
telemetry, fail, network_allowance_ms, stream_end_ms, stream_open_ms, sentence_stall_ms,
resume_tokens, surface, verify, outage_posture, require_api_key, count_stalls_on_breaker)`. `configure(...)` changes any of them in place for the
next call, except `verify`. `verify` is True (certifi and `SSL_CERT_FILE` / `SSL_CERT_DIR`,
as httpx), a CA bundle path, your own `ssl.SSLContext` or False; one context serves httpx
and the stream alike. `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` are honoured on every
route (the stream tunnels through the proxy with CONNECT). `telemetry` receives a `TelemetryEvent`
per call (`check`, `check.failed`, `stream.end`, `stream.cut`, `breaker.open`,
`config_error`, …) with stage, decision, failure, status, latency, ledger id and finding
categories — never content, never a key, never a token. The package writes no log of its
own.

`thulpar.CONTRACT_VERSION` is the check-API version the package tracks; `thulpar.schema()`
is the vendored JSON Schema, for an integration's own contract tests.

## Development

```bash
cd packages/sdk-py
uv sync                      # vendors docs/contracts/json/check-api.schema.json into the package
uv run pytest                # tests against an in-process fake Thulpar on a real port
uv run ruff check . && uv run ruff format --check .
uv sync --group adapters && uv run --group adapters pytest tests/adapters   # the integrations, against the pinned frameworks
uv build --wheel             # the only published artefact (no sdist); the version is src/thulpar/_version.py
THULPAR_E2E_URL=… THULPAR_E2E_KEY=… uv run pytest -m e2e   # against a real thulpar-api
```

Copyright (c) 2026 Abra Partners. All rights reserved. Use of this package is subject to
the Thulpar licence agreement.
