> ## Documentation Index
> Fetch the complete documentation index at: https://docs.enkryptai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Red Teaming Through the Relay Bridge

> End-to-end walkthrough: red-team an LLM inside your own network over a single outbound connection, with no inbound firewall rule

Some models cannot be reached from the internet at all — a vLLM server on a private
subnet, an internal inference gateway, a model running on a laptop. The **Relay bridge**
red-teams those without opening a hole in your firewall.

You run a small bridge process inside your own network. It holds one **outbound** WSS
connection to Enkrypt on port 443. Enkrypt pushes OpenAI-shaped `chat.completions`
requests down that socket, the bridge calls your internal LLM over your own intranet, and
the response returns the same way. Nothing in Enkrypt's cloud ever dials your model, and
your firewall never has to accept an inbound connection.

<Note>
  `target.metadata.relay`, used throughout this page, is being rolled out. If a run returns
  `422` naming `target.metadata.relay`, your environment is on the previous release: send the
  relay block as `target.relay` instead, which stays supported and is described under
  [Inline target](#inline-target). Everything else on this page is unchanged.
</Note>

Substitute whichever host your Enkrypt contact gives you for the examples below.

## Prerequisites

* An Enkrypt AI API key from [app.enkryptai.com](https://app.enkryptai.com/)
* A machine **inside your network** that can reach the LLM and can make outbound HTTPS
  connections on port 443
* The bridge itself, which ships in the Python SDK

```bash Shell theme={"system"}
pip install enkryptai-sdk requests
```

## Step 1 — Run the bridge

`pip install enkryptai-sdk` installs an `enkryptai-relay` console script. Run it on the
machine inside your network. It needs an API key and a bridge id, and nothing else.

| Variable                | Meaning                                                                                                                                                   |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ENKRYPT_API_KEY`       | Your Enkrypt API key. Set it in the environment — the equivalent `--api-key` flag lands in your shell history and process list.                           |
| `RELAY_BRIDGE_ID`       | A name you choose, e.g. `my-laptop`. Must equal `target.metadata.relay.bridge_id` in the run request — this is the one value both sides have to agree on. |
| `TARGET_BASE_URL`       | Your internal LLM's base URL *as the bridge sees it*, e.g. `http://127.0.0.1:11434/v1`.                                                                   |
| `RELAY_SERVER_BASE_URL` | The WSS address to dial: `wss://api.enkryptai.com/redteam/relay/v1/relay/bridge`. Use the host your Enkrypt contact gave you.                             |

<CodeGroup>
  ```bash Shell theme={"system"}
  export ENKRYPT_API_KEY="<your-enkrypt-api-key>"
  export RELAY_BRIDGE_ID="my-laptop"
  export TARGET_BASE_URL="http://127.0.0.1:11434/v1"
  export RELAY_SERVER_BASE_URL="wss://api.enkryptai.com/redteam/relay/v1/relay/bridge"

  enkryptai-relay
  ```

  ```python Python SDK theme={"system"}
  # Same configuration, for embedding the bridge in a supervisor or a Kubernetes sidecar
  from enkryptai_sdk import RelayBridge

  bridge = RelayBridge.from_env()

  # Blocks, and reconnects with backoff if the socket drops
  bridge.run()
  ```
</CodeGroup>

On success the bridge logs:

```text theme={"system"}
connected as bridge_id=my-laptop
```

<Note>
  There is **no user id to configure.** Authentication is the API key alone. The gateway
  resolves the owning account from it and stamps that identity on both the bridge socket and
  the runs you submit — that is how a run finds the right bridge, and why two customers can
  both name a bridge `my-laptop` without colliding.
</Note>

## Step 2 — Confirm the bridge is connected

<CodeGroup>
  ```shell cURL theme={"system"}
  curl -X GET "https://api.enkryptai.com/redteam/relay/v1/relay/bridges/my-laptop/status" \
    -H "apikey: $ENKRYPTAI_API_KEY"
  ```

  ```python Python theme={"system"}
  import os
  import requests

  BASE_URL = "https://api.enkryptai.com"
  HEADERS = {"apikey": os.getenv("ENKRYPTAI_API_KEY")}

  status = requests.get(
      f"{BASE_URL}/redteam/relay/v1/relay/bridges/my-laptop/status", headers=HEADERS
  ).json()

  print(status)
  ```
</CodeGroup>

```json JSON theme={"system"}
{ "bridge_id": "my-laptop", "connected": true }
```

The answer is scoped to your own account, so `connected: false` covers "not running",
"never existed" and "registered by somebody else" alike rather than leaking which it was.

This is also the **only** reachability check that works for a relay target — see
[Checking reachability](#checking-reachability) below.

## Step 3 — Point a red team run at the bridge

Routing through the bridge is a property of the `target`, not a separate endpoint. There
are two ways to express it.

### Inline target

`POST /rt/redteam`, with the relay block in the request body:

```json POST /rt/redteam theme={"system"}
{
  "target": {
    "endpoint": "https://internal-llm.example.local/v1/chat/completions",
    "api_key": "",
    "model_name": "mock-model",
    "provider": "openai_compatible",
    "connect_via_relay": true,
    "metadata": {
      "relay": {
        "bridge_id": "my-laptop",
        "target_endpoint": "http://127.0.0.1:11434/v1/chat/completions",
        "model_name": "internal-model-name"
      }
    }
  },
  "risk_categories": { "safety_harm": { "attack_config": { "basic": {} } } },
  "generation_config": { "max_prompts": 5, "include_standard_library": false }
}
```

The relay-specific parts:

* **`connect_via_relay: true`** is the only switch. There is no `provider: "relay"`. It
  stays at the **root** of `target`, not inside `metadata`.
* **`target.endpoint`** is kept for labelling and audit only. **Nothing in Enkrypt's cloud
  ever dials it**, so it may be an address that only resolves inside your network.
* **`target.api_key`** is `""`. The **bridge** authenticates to your internal LLM, not
  Enkrypt. Credentials for the internal LLM go in `metadata.relay.target_headers`, which
  the bridge forwards verbatim. Never put them here.
* **`metadata.relay.target_endpoint`** is the URL the bridge actually POSTs to inside your
  network.
* **`metadata.relay.model_name`** is the identifier your internal LLM expects. It need not
  match `target.model_name`, which is only the label the run is reported under.
* **`metadata.relay.target_headers`** is optional. Omit the key entirely rather than
  sending an empty object.

The response is a `run_id` and `status: "queued"`.

<Warning>
  `target.metadata` accepts **`relay` and nothing else**. Any other key — `metadata.tenant_id`,
  `metadata.aws_region`, provider-specific metadata in general — is a `422`. The `/rt` APIs
  cannot express provider-specific metadata at all, and accepting it silently would mean the
  run attacks a differently-configured target and reports success on it. Unknown keys
  *inside* `relay` are a `422` too.
</Warning>

<Note>
  `target.relay` — the bare spelling, without the `metadata` wrapper — is an older form and
  is still accepted, so existing integrations keep working. Prefer `metadata.relay`: it is
  the spelling used everywhere else in the chain, including what a saved model stores. Full
  field reference:
  [`target.metadata.relay`](/get-started/redteam/configuration-reference#target-metadata-relay).
</Note>

### Saved model

Store the relay configuration once and reuse it. `POST /models/add-model`:

```json POST /models/add-model theme={"system"}
{
  "model_saved_name": "internal-llm",
  "model_version": "v1",
  "testing_for": "foundationModels",
  "model_name": "internal-model-name",
  "model_config": {
    "model_provider": "openai_compatible",
    "endpoint": {
      "scheme": "https",
      "host": "internal-llm.example.local",
      "port": 443,
      "base_path": "/v1/chat/completions"
    },
    "connect_via_relay": true,
    "metadata": {
      "relay": {
        "bridge_id": "my-laptop",
        "target_endpoint": "http://127.0.0.1:11434/v1/chat/completions",
        "model_name": "internal-model-name"
      }
    }
  }
}
```

Then run it with `POST /rt/model/redteam` and **no `target` in the body**, naming the
saved model in headers. The gateway resolves the stored model into the target for you and
returns `202` with a `run_id`.

```shell cURL theme={"system"}
curl -X POST "https://api.enkryptai.com/rt/model/redteam" \
  -H "apikey: $ENKRYPTAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Enkrypt-Model: internal-llm" \
  -H "X-Enkrypt-Model-Version: v1" \
  -d '{
    "risk_categories": { "safety_harm": { "attack_config": { "basic": {} } } },
    "generation_config": { "max_prompts": 5, "include_standard_library": false }
  }'
```

<Warning>
  **Give `endpoint` all four keys** — `scheme`, `host`, `port` and `base_path`. All four are
  required. Some providers have built-in defaults that paper over an omission;
  `openai_compatible` has none, so leaving any one of them out fails the save.
</Warning>

<Note>
  `connect_via_relay` and `metadata.relay` are a pair, and `metadata.relay` cannot share the
  `metadata` object with other provider metadata. A model that sets the flag without a relay
  block, a relay block without the flag, or a relay block alongside provider metadata is
  rejected with a `400` naming the model when a `/rt/model/*` endpoint tries to use it. That
  is deliberate: a half-configured relay would otherwise be dialled *directly*, at an
  address that only resolves inside your network.
</Note>

## Step 4 — Track the run and read the results

These are the same endpoints as any other run — nothing about reading a relay run is
special.

| Call                                    | Returns                                                                     |
| --------------------------------------- | --------------------------------------------------------------------------- |
| `GET /rt/runs/{run_id}`                 | The run's current `status` — poll it until the run reaches a terminal state |
| `GET /rt/runs/{run_id}/results`         | The summary: `overall_asr` plus the per-category breakdown                  |
| `GET /rt/runs/{run_id}/records?limit=N` | The individual prompts and your internal LLM's replies                      |

```shell cURL theme={"system"}
curl -X GET "https://api.enkryptai.com/rt/runs/$RUN_ID" -H "apikey: $ENKRYPTAI_API_KEY"
curl -X GET "https://api.enkryptai.com/rt/runs/$RUN_ID/results" -H "apikey: $ENKRYPTAI_API_KEY"
curl -X GET "https://api.enkryptai.com/rt/runs/$RUN_ID/records?limit=20" -H "apikey: $ENKRYPTAI_API_KEY"
```

See the [Quickstart](/get-started/redteam/quickstart) for the run lifecycle's status
values, what the summary and record shapes contain, and how to read an ASR.

<Info>
  Do not follow the submission response's `status_url`, `results_url` and `websocket_url`
  fields literally. They are relative and are not guaranteed to carry the `/rt/` prefix this
  API is published under, so requesting one as given can fail. Build the URL from `run_id`
  and the paths above instead.

  `websocket_url` is the **Server-Sent Events** progress stream, `/rt/runs/{run_id}/stream`,
  despite the name. The live log socket is a different field, `logs_url` — see Step 5.
</Info>

## Step 5 — Watch the logs while the run is still going

A relay run has a second side you cannot see from the outside: every attempt is a call out
to *your* network. The log stream is where that becomes visible while it is happening,
rather than after the fact in the records — which is why it is worth wiring up for a relay
run even if you would skip it for a hosted one.

This is a WebSocket, and it is a different stream from `websocket_url`:

|                                              | What it carries                                                                   |
| -------------------------------------------- | --------------------------------------------------------------------------------- |
| `logs_url` (this section)                    | The engine's own log lines, as text — the same log the dashboard's run view shows |
| `websocket_url` → `/rt/runs/{run_id}/stream` | Server-Sent Events with structured run progress                                   |

### Getting the URL

The run tells you the path. It is on the submission response, and on
`GET /rt/runs/{run_id}` if you no longer have that:

```json theme={"system"}
{
  "run_id": "rt-d47b302f-0b65-49ad-9900-98ef88f5b404",
  "logs_url": "eecab025-…/rt-d47b302f-…/internal-model-name/app.log"
}
```

That value is **relative**. Join it onto the log socket's base and append your API key:

```
wss://api.enkryptai.com/wss/redteam/v1/logs/tasks/<logs_url>?apikey=<your key>
```

<Warning>
  The key goes in the **query string**, not the `apikey` header, because a browser cannot set
  headers on a WebSocket handshake and this is the same route the dashboard uses. The
  assembled URL therefore contains your API key — do not paste it into a ticket, a log line
  or a shared notebook.
</Warning>

Two things about that path are worth knowing, because getting either wrong produces a
socket that opens normally and then simply stays silent rather than returning an error:

* the run id segment must be the **prefixed** one (`rt-…`), not the bare UUID;
* the first segment is the run's **owner**, and it must match the account your API key
  belongs to. Another account's run is a `403` at the handshake. A run started with an
  organization key is owned by the organization, not by you personally.

### Reading the stream

`curl` cannot do this; use a WebSocket client.

```shell websocat theme={"system"}
websocat "wss://api.enkryptai.com/wss/redteam/v1/logs/tasks/$LOGS_URL?apikey=$ENKRYPTAI_API_KEY"
```

```python Python SDK theme={"system"}
from enkryptai_sdk import RedTeamClient

rt = RedTeamClient(api_key=ENKRYPTAI_API_KEY)
run = rt.run_redteam(config=redteam_config)

for line in rt.stream_logs(run.run_id):
    print(line)
```

`stream_logs` resolves `logs_url` for you, so the run id is all you pass. It blocks and
ends on its own when the run does. `astream_logs` is the same stream for asyncio
(`async for line in rt.astream_logs(run.run_id)`), and `logs_url()` returns the assembled
URL if you would rather hold the socket yourself.

Lines already produced are replayed before the stream tails, so connecting part-way through
still gives you the run from the beginning — you do not have to race the job to start
watching. The server closes the socket once the run reaches a terminal state. Retention is
finite, so a run that finished long ago replays from its stored `app.log` instead.

<Note>
  Connections are rate limited **per IP**, not per key, and the limit is shared with the
  dashboard. Opening a socket per attempt in a loop will trip it; open one per run and keep
  it.
</Note>

## Step 6 — The SDK equivalent

`RTModelConfig.via_relay()` builds the whole relay target, emitting `metadata.relay`, so
the three values you actually own are the three you pass:

```python Python SDK theme={"system"}
from enkryptai_sdk import RTModelConfig

target = RTModelConfig.via_relay(
    bridge_id="my-laptop",
    endpoint="http://127.0.0.1:11434/v1/chat/completions",
    model_name="internal-model-name",
    target_headers={"Authorization": "Bearer local-llm-key"},
)
```

* `endpoint` is your LLM's URL *as the bridge sees it*. It fills both `target.endpoint`
  (the label) and `metadata.relay.target_endpoint` (what the bridge POSTs to).
* `provider` defaults to `openai_compatible`. It has no runtime effect under
  `connect_via_relay` — the relay wire is OpenAI-shaped end to end — but it survives as
  metadata.
* `remote_model_name=` overrides **only** the name sent to your internal LLM, for when it
  differs from the label you want the run reported under.
* Passing a non-empty `api_key=` **raises**, on purpose: a key there would be shipped to
  Enkrypt and then ignored, because the bridge is what authenticates to your LLM. Use
  `target_headers` instead.

Pass the target to `run_redteam` exactly as you would a hosted one; routing through the
bridge is a property of the target, so no client method changes. `RTModelConfig.hosted()`
is the symmetric constructor for a publicly reachable endpoint, which makes moving a run
between a hosted and an internal model a one-line edit.

## If the bridge drops mid-run

The run **pauses**; it does not fail. Calls made in that window are retried, the bridge
reconnects on backoff, and no test case is lost. You can restart the bridge process,
reboot the host, or lose the network for a while and pick the run up where it left off.

## Checking reachability

<Warning>
  **`POST /rt/model-health` and `POST /rt/model/model-health` do not support relay targets.**
  Both probes dial `target.endpoint` directly. On a relay target that field is only a
  placeholder for an address inside your own network, so the probe always reports unhealthy
  however healthy the bridge is.

  Use the bridge status endpoint from Step 2 instead:
  `GET /redteam/relay/v1/relay/bridges/{bridge_id}/status`.
</Warning>

Submitting the run itself is unaffected: the gateway knows not to health-probe a relay
target on the way in.

## Troubleshooting

| Symptom                                                | What it means                                                                                                                                            |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WSS close code `4400`                                  | The upgrade carried no `x-enkrypt-relay-bridge-id` header — `RELAY_BRIDGE_ID` is unset.                                                                  |
| WSS close code `4401`                                  | The API key was not accepted.                                                                                                                            |
| WSS close code `4403`                                  | The gateway could not resolve an owning account for the key.                                                                                             |
| The bridge reconnect-loops forever                     | It is pointed at an environment where the relay route is not served. Set `RELAY_SERVER_BASE_URL` — see the availability warning at the top of this page. |
| Model health reports unhealthy, but the bridge is up   | Expected. Health probes do not support relay targets — check the bridge status endpoint instead.                                                         |
| The run is submitted but no traffic reaches the bridge | `bridge_id` mismatch. The bridge's `RELAY_BRIDGE_ID` and the request's `metadata.relay.bridge_id` must be identical.                                     |

### Exact 422 strings for an inline target

An inline `target` is validated by the Red Team service. Every failure comes back as
`422 Unprocessable Entity` with a `detail` array; these are the strings verbatim, so you can
match on them.

```json 422 theme={"system"}
{
  "detail": [
    {
      "type": "value_error",
      "loc": ["body", "target"],
      "msg": "Value error, target.metadata.relay is required when connect_via_relay is true",
      "ctx": {
        "error": "target.metadata.relay is required when connect_via_relay is true"
      }
    }
  ]
}
```

| `type`            | `loc`                                               | `msg`                                                                                                            |
| ----------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `value_error`     | `["body","target"]`                                 | `Value error, target.metadata.relay is required when connect_via_relay is true`                                  |
| `value_error`     | `["body","target"]`                                 | `Value error, target.relay and target.metadata.relay are both set and disagree; send only target.metadata.relay` |
| `extra_forbidden` | `["body","target","metadata","<your key>"]`         | `Extra inputs are not permitted`                                                                                 |
| `extra_forbidden` | `["body","target","metadata","relay","<your key>"]` | `Extra inputs are not permitted`                                                                                 |

<Note>
  The `Value error, ` prefix is real — it is part of the `msg` string the API returns, not
  documentation punctuation. The unprefixed sentence is repeated in `ctx.error`, which is the
  easier field to match on exactly.

  The two `extra_forbidden` rows are the same message for two different mistakes: a key other
  than `relay` under `metadata` (row 3), and an unrecognised key *inside* the relay block —
  `timeout_s`, `offline_grace_s`, a relay endpoint or token — which do not exist as settings at
  all (row 4). The `loc` array is what tells them apart, and its last element names your key.
</Note>

### Exact 400 strings for a saved model

A saved model is projected into a target by the gateway *before* the service sees it, so a
relay misconfiguration there is a `400`, not a `422`, and it is raised when a `/rt/model/*`
endpoint uses the model rather than when you save it.

```json 400 theme={"system"}
{
  "code": 400,
  "error": "Bad request",
  "message": "Saved model 'internal-llm' sets connect_via_relay but carries no metadata.relay block naming the bridge to route through.",
  "request_id": "1a2b3c4d5e6f",
  "time": 1755388800.123
}
```

Every `message` opens with the subject — `Saved model '<your model name>'`, or
`The submitted target_model_configuration` if you sent a legacy target block inline — and
then reads:

| Rest of the `message`                                                                                                                                                                                                                                 | Cause                                                                                            |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| ` sets connect_via_relay but carries no metadata.relay block naming the bridge to route through.`                                                                                                                                                     | The flag is set, the relay block is missing.                                                     |
| ` carries a metadata.relay block but does not set connect_via_relay, so it would be called directly at an endpoint that only resolves inside your own network. Set connect_via_relay: true to route it through the bridge, or remove metadata.relay.` | The relay block is there, the flag is not. Rejected rather than dialled directly.                |
| ` carries metadata.<key> alongside metadata.relay. The /rt APIs can express relay routing but not provider-specific metadata, so this target would lose the latter silently. Use the legacy /redteam APIs for it instead.`                            | `metadata` holds provider metadata as well as `relay`. `<key>` is the offending key.             |
| ` has metadata.relay without a bridge_id, so there is no bridge to route the run through.`                                                                                                                                                            | `bridge_id` missing or empty.                                                                    |
| ` has metadata.relay without a target_endpoint, which is the URL the bridge calls inside the customer's network.`                                                                                                                                     | `target_endpoint` missing or empty.                                                              |
| ` has no model_name, on the target or on metadata.relay.`                                                                                                                                                                                             | Neither `model_name` is set; the relay block's value falls back to the target's.                 |
| ` uses provider-specific metadata, which the /rt APIs do not support. Use the legacy /redteam APIs for this target instead.`                                                                                                                          | `metadata` holds provider metadata and **no** relay block, so this is not a relay target at all. |

## Related

* [Configuration Reference](/get-started/redteam/configuration-reference#target-metadata-relay) — every `target.metadata.relay` field
* [Payload Examples](/get-started/redteam/examples#12-target-behind-the-relay-bridge) — the relay payload alongside every other Red Team payload shape
* [Relay Bridge Connect](/api-reference/redteam-v2-api-reference/endpoint/relay-bridge-connect) — the WSS socket and its headers
* [Relay Bridge Status](/api-reference/redteam-v2-api-reference/endpoint/relay-bridge-status) — the status endpoint
* [Stream Run Logs](/api-reference/redteam-v2-api-reference/endpoint/stream-run-logs) — the live log socket, and how its URL is built
* [Python SDK reference](/sdk-reference/python/introduction#relay-bridge) — `RelayBridge`, the bridge translation hooks, and the rest of the bridge environment contract
* [Testing Models Across Providers](/get-started/RedTeam_Models) — target configuration for publicly reachable models
* [Quickstart](/get-started/redteam/quickstart) — the full run cycle
