Metadata-Version: 2.4
Name: trustgate
Version: 0.15.1
Summary: Enterprise AI Governance & Shadow AI Hunter SDK
Author: TrustGate
License: MIT
Project-URL: Homepage, https://trustgate.ai
Project-URL: Documentation, https://trustgate.ai/help
Project-URL: Repository, https://github.com/trustgate/trustgate-python
Project-URL: Issues, https://github.com/trustgate/trustgate-python/issues
Keywords: trustgate,openai,gateway,n8n,github-actions,gitlab-ci,governance,shadow-ai
Classifier: Development Status :: 3 - Alpha
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: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.24.0
Requires-Dist: openai>=1.0.0
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.39.0; extra == "anthropic"
Provides-Extra: gemini
Requires-Dist: google-genai>=1.0.0; extra == "gemini"
Provides-Extra: langchain
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
Requires-Dist: langchain-openai>=0.2.0; extra == "langchain"
Provides-Extra: llamaindex
Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
Provides-Extra: dev
Requires-Dist: anthropic; extra == "dev"
Requires-Dist: langchain; extra == "dev"
Requires-Dist: langchain-openai; extra == "dev"
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Requires-Dist: respx>=0.20; extra == "dev"
Requires-Dist: jsonschema>=4.0; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"

# trustgate-python

TrustGate Python SDK with **Automatic Context** for enterprises. Route your LLM
calls through your TrustGate gateway and auto-inject trace and workflow context
(n8n, GitHub Actions, GitLab CI).

You do that with a **client you construct and pass around** — `TrustGate` /
`AsyncTrustGate` — configured explicitly with your gateway's address. That is
the whole integration, and it is the shape the Java and .NET SDKs ship too.

> **Run-time patching is deprecated.** `patch_all()`, `patch_openai()`,
> `patch_anthropic()` and `patch_google_generativeai()` still work and are
> documented at the end of this file, but they rewire another library inside
> your process at run time, which enterprise security review rejects. They are
> scheduled for removal in a future major. Nothing breaks today; new code
> should use the client below.

## Install

```bash
pip install -e .
```

## Configuration

### The gateway address is explicit — there is no default

TrustGate is self-hosted: your gateway is on your network, under your name, and
the SDK has no idea where. So it does not have a fallback address and will not
invent one.

For the direct client, the address is a **required argument**. There is no
environment fallback and nothing to resolve — the client you built is pointed
where you pointed it:

```python
from trustgate import TrustGate

tg = TrustGate(base_url="https://your-trustgate-gateway.example")
```

`api_key` is the TrustGate agent key (`tg_sk_...`) sent as
`Authorization: Bearer`. Pass it explicitly, or leave it out and it is read
from `TRUSTGATE_API_KEY`.

The deprecated patch entry points resolve their address differently — argument,
then `TRUSTGATE_BASE_URL`, then `TrustGateConfigError`. See
[Run-time patching (deprecated)](#run-time-patching-deprecated).

### `TRUSTGATE_DISABLE=1` — the kill switch

Set it in the environment and every *instrumentation* entry point becomes a
no-op that returns cleanly: the deprecated patchers patch nothing, the tracer
records and flushes no spans, `declare_retrieval()` records nothing, and no
address is required. Nothing raises.

```bash
export TRUSTGATE_DISABLE=1
```

It exists so a platform team can switch the instrumentation off in a deployed
service from the environment, without editing or redeploying that service's
code. `1`, `true`, `yes` and `on` are all accepted (case-insensitive).

It does **not** disable the direct `TrustGate` / `AsyncTrustGate` client — that
is your own explicit call to your own gateway, not instrumentation.

## Usage

### 1. The direct client

`tg.chat.completions.create()` takes the same arguments as
`openai.chat.completions.create()`; TrustGate headers are added automatically.

```python
from trustgate import TrustGate

tg = TrustGate(
    base_url="https://your-trustgate-gateway.example",
    api_key="tg_sk_...",   # or the TRUSTGATE_API_KEY environment variable
)

response = tg.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "Hello"}],
    temperature=0.7,
)
# response is the same shape as OpenAI (e.g. response["choices"][0]["message"]["content"])
```

Nothing global changes. No import is rebound, no third-party class is modified,
and two clients pointed at two gateways coexist in one process.

A refusal is an exception, not a surprising 200: non-2xx responses raise the
typed `trustgate.exceptions` hierarchy (auth / bad-request / context-window /
rate-limit / permission / policy-violation / provider), and a 200 whose
`finish_reason` is `content_filter` raises `TrustGateContentFilterError` so a
blocked response cannot be mistaken for a normal one.

### Reading the gateway's decision

The gateway answers with a **decision**, not just content. Read it with
`trustgate_decision()`, which takes either the returned response or an error you
caught:

```python
from trustgate import TrustGate, trustgate_decision

response = tg.chat.completions.create(model="gpt-4o", messages=[...])

decision = trustgate_decision(response)
if decision is not None and decision.is_altered:
    # We delivered the answer, but policy EDITED it on the way through.
    log.warning("edited by policy: %s (%s)", decision.altered, decision.code)
```

Six values, and the split that matters is whether you got the model's content:

| `decision.decision` | You got content | Meaning |
|---|---|---|
| `allow` | yes | A normal answer. Everything configured ran. |
| `allow_altered` | yes, **edited** | Policy changed the text. `decision.altered` carries the counts. |
| `not_checked` | yes, **unverified** | Delivered before an inspection finished, or with one that could not run. |
| `block` | no | Blocked on content. |
| `refuse` | no | Refused by policy. Not about the content. |
| `fail` | no | Nobody decided. |

`decision.delivered` is the short form of that column. `decision.code` names the
finding (`dlp`, `jailbreak`, `inspector_timeout`, …) — log it, but do not branch
on it: that vocabulary grows.

**`trustgate_decision()` returns `None` when the gateway did not send a
decision, and that is common today.** The envelope is not yet emitted on every
surface, and a `legacy` account is served the headers with the body object
withheld. `None` is not a decision — do not treat it as `allow`, and do not
treat it as a failure. Everything about such a response behaves exactly as it
did before this feature existed.

Both carriers are read: the top-level `trustgate` object on a 200 (nested inside
`error` on an error), and the `x-trustgate-*` response headers, which are the
one placement that is never switched off. The names come from
`contracts/decision-envelope.v1.md`.

### Recognising a block: `is_trustgate_block(err)`

A block reaches you as an exception. Two things can be true of an exception from
a gateway call — TrustGate stopped it, or the model provider rejected it — and
`is_trustgate_block()` tells them apart inside your own `except`:

```python
from trustgate import TrustGate, is_trustgate_block

try:
    response = tg.chat.completions.create(model="gpt-4o", messages=[...])
except Exception as err:
    if is_trustgate_block(err):
        return "That request was blocked by your organization's policy."
    raise            # a provider error, a bad request, a rate limit — yours to handle
```

It **inspects** an error and answers yes or no. It patches nothing, wraps
nothing and replaces nothing, so it is safe to call on any exception, including
one that has nothing to do with TrustGate.

It **fails closed**: a TrustGate block signal it cannot fully parse — an
unrecognised decision, a reason code newer than your SDK — answers `True`. A
wrong "no" would send blocked content down a path that assumed it was
delivered; a wrong "yes" costs one unnecessary trip through your review branch.

### 2. Async: `AsyncTrustGate`

Same surface, same arguments, awaited:

```python
from trustgate import AsyncTrustGate

async with AsyncTrustGate(
    base_url="https://your-trustgate-gateway.example",
    api_key="tg_sk_...",
) as tg:
    response = await tg.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Hello"}],
    )
```

Outside a `with` block, call `await tg.aclose()` when you are done.

### 3. Multi-model

The direct client speaks one wire shape — the OpenAI-compatible
`/v1/chat/completions` your gateway already serves — and `model` selects the
route. Which model names exist is your gateway's `model_config.json`, not this
SDK's business, so an Anthropic or Gemini model is the same call with a
different string:

```python
tg.chat.completions.create(
    model="claude-sonnet-4-5",
    messages=[{"role": "user", "content": "Hello"}],
)
```

**Gemini's own request shape**, for a gateway that proxies Gemini natively, has
a dedicated client. It is a client, not a patch, and it is not deprecated
(optional dep: `pip install trustgate[gemini]` if you also want the Google SDK
present):

```python
from trustgate import TrustGateGeminiClient

client = TrustGateGeminiClient(
    base_url="https://your-gateway.example",
    api_key="tg_sk_...",
)
response = client.generate_content(model="gemini-1.5-flash", contents="Hello")
```

## Automatic context (bridge headers)

The SDK detects the environment and sets:

- **`x-trustgate-trace-id`** – Execution/pipeline id (e.g. `N8N_EXECUTION_ID`, `GITHUB_RUN_ID`, `CI_PIPELINE_ID`) or a generated UUID.
- **`x-trustgate-workflow-name`** – Workflow/pipeline name when available (n8n workflow, GitHub workflow, GitLab job).
- **`x-trustgate-workflow-step`** – Optional step name for granular traceability (see [Granular traceability](#granular-traceability)); in n8n can be auto-set from `N8N_NODE_ID` / `N8N_NODE_NAME`.
- **`x-trustgate-source`** – One of `n8n`, `github_actions`, `gitlab_ci`, or `local_script` (when no env is detected, for Shadow AI monitoring).

Detection is based on environment variables:

| Source        | Env vars (examples)                          |
|---------------|----------------------------------------------|
| n8n           | `N8N_EXECUTION_ID`, `N8N_WORKFLOW_NAME`, `N8N_NODE_ID`, `N8N_NODE_NAME` |
| GitHub Actions| `GITHUB_ACTIONS`, `GITHUB_RUN_ID`, `GITHUB_WORKFLOW` |
| GitLab CI     | `GITLAB_CI`, `CI_PIPELINE_ID`, `CI_JOB_NAME` |

## Metadata

Every request includes **source_tool** metadata (in the `x-trustgate-source-tool` header as JSON):

- `sdk_version` – TrustGate Python SDK version
- `python_version` – Python version (e.g. `3.11.5`)
- `os` – OS name (e.g. `Windows`, `Linux`)

You can add or override keys per request with `source_tool_override`:

```python
tg.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    source_tool_override={"step_name": "extract", "stage": "preprocessing"},
)
```

## Granular traceability

Use **`workflow_step`** so the gateway can show different parts of the same workflow (e.g. n8n) as separate steps in the **Agent Intelligence Gantt** chart. Without it, all calls in one run look like a single block; with it, you see segments like `Data_Extraction`, `Final_Summary`, etc.

```python
tg.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    workflow_step="Data_Extraction",
)
# later in the same workflow
tg.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    workflow_step="Final_Summary",
)
```

**n8n (automatic):** When running inside n8n, the SDK can set `x-trustgate-workflow-step` automatically from `N8N_NODE_ID` or `N8N_NODE_NAME`, so each node appears as its own step in the Gantt without code changes.

## Retrieval (RAG)

Retrieved documents are the highest-risk text in a RAG application and the
least visible: by the time they reach the gateway they look like part of the
user's prompt. Declaring them separately puts them on the gateway's dedicated
`ingress-rag` surface, which sees the corpus, the per-document scores and the
per-document previews — so it can act on a poisoned document without treating
every long prompt as suspicious.

### `rag_context=` — declare the retrieval on the call

```python
docs = vectorstore.similarity_search(question, k=5)

tg.chat.completions.create(
    model="gpt-4o-mini",
    messages=[...],
    rag_context={
        "corpus": "policy_docs_v2",
        "query": question,
        "docs": [{"id": "POL-1", "score": 0.92, "preview": "..."}],
    },
)
```

This is the explicit path, and it always wins: a body that already carries
`trustgate_metadata.rag_context` is never rewritten by anything else in the SDK.

### `attach_rag_context()` — for a body you build yourself

`make_rag_context(corpus=..., query=..., docs=...)` builds the block and
`attach_rag_context(body, rag_context)` merges it into
`body["trustgate_metadata"]["rag_context"]` without clobbering the other
`trustgate_metadata` fields you may have set:

```python
from trustgate import attach_rag_context, make_rag_context

body = {"model": "gpt-4o-mini", "messages": [...]}
attach_rag_context(body, make_rag_context(
    corpus="policy_docs_v2",
    query=question,
    docs=[{"id": "POL-1", "score": 0.92, "preview": "..."}],
))
```

### What is sent, and what is not

* **Memory only.** No disk, no logs, no span, no side channel. The only place a
  document preview ever goes is the body of the request to *your* gateway.
* **Capped to the contract** (`contracts/rag_context.v1.schema.json`): 64
  documents and 2048 preview characters.
* **`TRUSTGATE_DISABLE=1`** makes the ambient declaration APIs below no-ops. It
  does not touch a `rag_context=` you passed yourself — that is your own body,
  on your own client.

### Declaring retrieval away from the call site

`declare_retrieval()`, `retrieval_scope()` and the LangChain / LlamaIndex
collectors let a retriever declare what it returned without touching the model
call at all — **on the direct client, since 0.14.0.** Until then the only thing
that put a declaration on the wire was a patched provider client; a
`declare_retrieval()` before a `TrustGate` call was recorded and never sent.
`TrustGate` and `AsyncTrustGate` now mount the joining transport themselves, so
nothing is patched and nothing has to be passed to the call:

```python
from trustgate import TrustGate, declare_retrieval

tg = TrustGate(base_url="https://your-trustgate-gateway.example", api_key="tg_sk_...")

def retrieve(question):
    docs = vectorstore.similarity_search(question, k=5)
    declare_retrieval(docs, corpus="policy_docs_v2", query=question)
    return docs

docs = retrieve(question)                      # anywhere; any distance away
tg.chat.completions.create(                    # the declaration rides this call
    model="gpt-4o-mini",
    messages=build_messages(docs, question),
)
```

`retrieval_scope(docs, corpus=...)` does the same bounded to a `with` block.
Four properties hold on the direct client exactly as they do on a patched one:

* **Nothing is allocated when nothing was declared.** With an empty bus the
  bytes on the wire are byte-for-byte what a plain `httpx` client sends, and
  the request body is never even parsed.
* **A declaration is consumed by the request that sends it** and does not
  attach to the next one.
* **`rag_context=` always wins.** If you pass it explicitly and have also
  declared ambiently, the explicit value is sent and the ambient one is
  discarded — not deferred to your next call.
* **A transport you passed yourself is never replaced.** `TrustGate(...,
  transport=your_transport)` keeps yours; the joiner is a default, not an
  override. Such a client does not attach declarations, which shows at the
  gateway as an undeclared request.

---

## LangChain (and any SDK that takes an HTTP client)

A LangChain agent never calls `trustgate.TrustGate`. It calls `ChatOpenAI`,
which is `openai` underneath — so without this, the retrieval joiner is not
mounted, the `x-trustgate-*` headers are never built, and the gateway's
decision does not reach you at all.

The integration is the HTTP client the framework already accepts. **Nothing is
patched.**

```python
from langchain_openai import ChatOpenAI
from trustgate import trustgate_http_client

llm = ChatOpenAI(
    model="gpt-4o-mini",
    base_url=f"{GATEWAY}/v1",
    api_key=AGENT_KEY,
    http_client=trustgate_http_client(api_key=AGENT_KEY),
    include_response_headers=True,   # REQUIRED to read the decision — see below
)
```

> **`include_response_headers=True` is not optional if you want to read the
> gateway's decision.** LangChain drops the body carrier on this path and, with
> the flag off, drops the headers too — so every decision reads `None` with no
> error, no exception and no empty answer to notice: a `block` looks exactly
> like an `allow`. The SDK warns once per process when it can see this has
> happened (it has seen a decision arrive on the wire and been handed nothing to
> read it from), but the warning is a backstop, not a substitute for the flag.

`trustgate_async_http_client()` is the same thing for `http_async_client=`.

What the client carries:

* **The retrieval joiner** — the same transport `TrustGate` mounts, so
  `declare_retrieval()` / `retrieval_scope()` reach the wire from here too,
  with the same four properties (zero cost on an empty bus, explicit beats
  ambient, consumed by one request, a miss rather than a merge).
* **One trace and one session for the whole agent run.** Both ids are minted
  when the client is constructed, so every hop of a tool loop lands in one
  session in the audit screen.
* **`TrustGate-Block-Encoding: explicit`** — a block comes back as a 400 naming
  the finding code, not as a 200 whose empty content looks like a model that
  had nothing to say. Change it with `block_encoding="..."`, or switch it off
  with `block_encoding=None`.
* Your own `transport=` is never replaced, only defaulted.

### Reading the decision through LangChain

The body carrier does not survive this path. Measured against
langchain-openai 1.6.1: `openai` keeps the top-level `trustgate` object on
`ChatCompletion.model_extra`, but LangChain builds `response_metadata` from a
fixed key list and never hands it to you. **The headers do survive**, which is
what `include_response_headers=True` is for:

```python
from trustgate import decision_from

message = llm.invoke("...")
d = decision_from(None, message.response_metadata["headers"])

if d is not None and d.is_altered:
    print("policy edited this:", d.code)
```

`None` still means the gateway said nothing — not an `allow`. If you are
getting `None` on every call, check `include_response_headers=True` first: it is
the difference between "the gateway did not decide" and "the decision was
thrown away before it reached you", and only one of those is your gateway's
answer.

On an error you are holding the PROVIDER's exception, not ours, and
`trustgate_decision()` / `is_trustgate_block()` read it directly:

```python
import openai

try:
    agent.invoke(...)
except openai.BadRequestError as err:
    if is_trustgate_block(err):
        print("blocked:", trustgate_decision(err).code)
```

### Where to declare a retrieval when the caller is an agent

Measured against langgraph 1.2.11: a `declare_retrieval()` made **inside** a
graph node — inside a tool, say — does not reach the wire. Each node runs in
its own `contextvars.copy_context()`, so the write lands in a copy that is
discarded when the node returns. A copy *inherits* what was already set, so:

> **Declare where you retrieve, in the flow that calls the agent.**

which is where a RAG retrieval happens anyway:

```python
with retrieval_scope(docs, corpus="employee_handbook", query=question):
    result = agent.invoke({"messages": [{"role": "user", "content": question}]})
```

### The runnable demo

`examples/langchain_agent_demo.py` is a complete agent: two function tools,
`ChatOpenAI` with the client above, `tg_langchain.install()` for retrieval,
non-streaming, no patching.

```bash
export TRUSTGATE_BASE_URL=https://your-gateway.example
export TRUSTGATE_API_KEY=tg_sk_...
export TRUSTGATE_MODEL=gpt-4o-mini          # a route in your model_config.json
pip install "trustgate[langchain]" langchain
python examples/langchain_agent_demo.py
```

It prints the trace id first, so you can find the run in the audit screen
before it has finished, then one clean line per moment — never a stack trace:

```
trace id: 1ecdb9d3-e66c-40c4-b830-591d03990d15
moment 1: allow (clean)
moment 2: allow_altered (pii_masked)
moment 3: blocked: prompt_injection_detected
```

| Moment | The question | What it shows |
|---|---|---|
| 1 | a normal handbook question | the agent's tool loop runs, the retrieval is declared, and the gateway **allows** |
| 2 | the same shape, carrying an employee's name and SSN | **`allow_altered`** — the model answered, and policy edited what it was shown. The decision reached the caller through a framework that drops the body carrier |
| 3 | a prompt-injection attempt | the gateway **blocks**; the customer catches `openai.BadRequestError`, and `is_trustgate_block(err)` recognises it and names the finding code |

Moment 3's `except` catches the provider's exception type, not a TrustGate one
— that is what a customer's code actually holds on this path.

---

## Run-time patching (deprecated)

`patch_all()`, `patch_openai()`, `patch_anthropic()` and
`patch_google_generativeai()` are **deprecated since 0.13.0**. They rewire
another library at run time, which enterprise security review rejects: after
the call, `openai.OpenAI` and `anthropic.Anthropic` are no longer the classes
the customer installed and audited, and nothing at the import site says so.

Nothing breaks today. They behave exactly as they always have, they are still
tested, and the only change is a `DeprecationWarning` naming the version, the
reason and the replacement. **Removal is scheduled for a future major release.**
Port to [the direct client](#1-the-direct-client) when you next touch the code.

These entry points resolve their gateway address in this order:

1. the `base_url` argument (`patch_all(base_url=...)`)
2. the `TRUSTGATE_BASE_URL` environment variable
3. otherwise `TrustGateConfigError` is raised, before anything is patched

The error is raised at patch time, never mid-request, and names the environment
variable and the argument. Catch it as `trustgate.TrustGateConfigError` (a
subclass of `TrustGateError`). Provider-specific overrides exist and each falls
back to `TRUSTGATE_BASE_URL`: `TRUSTGATE_ANTHROPIC_BASE_URL`,
`TRUSTGATE_GEMINI_BASE_URL`.

### `patch_all()` — all SDKs at once

Applies TrustGate headers and gateway routing to OpenAI, Anthropic, and Google
Gemini in one call.

```python
import trustgate
trustgate.patch_all()  # or patch_all(base_url="https://your-gateway.example")
# Then use openai, anthropic, or google.generativeai as usual — all requests get x-trustgate-* headers
```

### `patch_openai()` — route existing OpenAI code

Routes all `openai.OpenAI` traffic through TrustGate and injects context
headers:

```python
import trustgate
trustgate.patch_openai(base_url="https://your-trustgate-gateway.example")

import openai
client = openai.OpenAI(api_key="...")  # base_url is overridden; x-trustgate-* headers added
resp = client.chat.completions.create(model="gpt-4o", messages=[...])
```

`workflow_step` can be set once for the whole process, so every call from it
sends the same Gantt step:

```python
trustgate.patch_openai(
    base_url="https://your-gateway.example",
    workflow_step="CI_CodeReview",
)
```

### `patch_anthropic()` and `patch_google_generativeai()`

All patches use the same **context** (`context.build_trustgate_headers`) for
Shadow AI detection (e.g. `local_script`, n8n, GitHub Actions, GitLab CI).

**Anthropic** (optional dep: `pip install trustgate[anthropic]`):

```python
import trustgate
trustgate.patch_anthropic(base_url="https://your-gateway.example")

import anthropic
client = anthropic.Anthropic(api_key="...")  # base_url and x-trustgate-* headers set
# Your Anthropic key travels to the gateway (anthropic builds x-api-key from api_key= at
# request time). The gateway does not authenticate with it and does not store it — it uses
# its own configured provider key upstream. See trustgate.patch's module docstring.
```

**Gemini** — patch the new `google.genai` Client (optional dep:
`pip install trustgate[gemini]`):

```python
import trustgate
trustgate.patch_google_generativeai(base_url="https://your-gateway.example")
```

`patch_google_generativeai` is the one patcher an address is optional for: with
none set it injects headers and leaves the client pointed at Google, which is
what you want for Shadow AI detection of traffic you are not proxying. Routing
is applied only when you name a Gemini address explicitly. For traffic you do
route through the gateway, use
[`TrustGateGeminiClient`](#3-multi-model) instead — it patches nothing.

### What was patched: one log line

A successful patch emits a single `INFO` record on the `trustgate` logger naming
each symbol it rebound and the address it now points at:

```
TrustGate SDK patched openai.OpenAI -> https://your-gateway.example/v1, anthropic.Anthropic -> https://your-gateway.example, ...
```

It carries symbol names and the resolved base URL only — never an API key,
never a header value. To see it:

```python
import logging
logging.basicConfig(level=logging.INFO)
```

### Ambient retrieval declaration rides the patched transport

`declare_retrieval()` and `retrieval_scope()` are **not** deprecated, and since
0.14.0 they work on [the direct client](#declaring-retrieval-away-from-the-call-site)
too — which is where new code should use them. The example below is the same
mechanism on a patched client, kept for code that has not been ported yet.

Call `declare_retrieval()` right after your retriever returns. You do **not**
have to be anywhere near the model call, and you do not have to touch the call
itself:

```python
from trustgate import declare_retrieval, patch_all

patch_all(base_url="https://your-gateway.example")

def answer(question):
    docs = vectorstore.similarity_search(question, k=5)
    declare_retrieval(docs, corpus="policy_docs_v2", query=question)
    return build_and_call(docs, question)   # any patched client, any distance away
```

The declaration rides a `contextvars.ContextVar` and is attached to the **next**
outgoing request as `trustgate_metadata.rag_context`. It is consumed by that
request: retrieval declared for one call never reaches the next one. If no model
call follows, nothing is sent anywhere — the declaration is discarded when the
context ends.

`docs` takes what your retriever already returns: plain strings, dicts (any of
`preview` / `text` / `content` / `page_content` / `body`), LangChain
`Document`s, LlamaIndex nodes and `NodeWithScore`s, or anything carrying a
`.metadata` with `id` / `score` / `source`. A single document may be passed
unwrapped. Anything unreadable is dropped rather than stringified.

To bound a declaration to a block instead:

```python
from trustgate import retrieval_scope

with retrieval_scope(docs, corpus="policy_docs_v2", query=question):
    answer = chain.invoke(question)
```

**LangChain and LlamaIndex: don't declare at all.** If you use either
framework, one line replaces the `declare_retrieval()` call entirely — the SDK
captures the retrieval itself:

```python
from trustgate.middleware import langchain as tg_langchain

tg_langchain.install(corpus="policy_docs_v2")
```

```python
from trustgate.middleware import llamaindex as tg_llamaindex

tg_llamaindex.install(corpus="docs_v2")
```

From then on every retriever in the process declares what it returned, and the
declaration is attached to the model call that follows. Nothing is passed to
`invoke()`, no `config={"callbacks": [...]}`, and neither collector touches the
outgoing request — they push to the same bus `declare_retrieval()` writes to.

This works for the shapes that previously needed a manual declaration:

| Shape | Works |
|---|---|
| LCEL — `retriever \| prompt \| llm` | yes |
| `RetrievalQA`-style chain | yes |
| Retriever called outside the model's chain | yes |
| LlamaIndex retriever / query engine | yes |
| A worker thread that never inherited the installing context | yes |

`install()` returns `True` when the collector is live and `False` when the
framework is not installed — importing `trustgate.middleware.langchain` on a
machine with no LangChain is a supported no-op, not an ImportError.

Calling both `install()` and `declare_retrieval()` for the same retrieval is
safe: the duplicate documents are de-duplicated by `(id, preview)` and carried
once.

`lc_documents_to_rag_context()` and `nodes_to_rag_context()` are unchanged for
callers building a `rag_context` block by hand.

**Nothing retrieved costs nothing.** With no declaration carried, the transport
returns on a single ContextVar read and the request bytes are byte-for-byte what
the unpatched client would have sent.

## Development

```bash
pip install -e ".[dev]"
pytest
```

## License

MIT
