Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,15 @@ weights in the process running it, and Otari does the first only. The second
belongs in the guardrails service `guardrails_url` points at, which is what the
`/profiles` half of this page describes, so the two catalogs divide on exactly
that line. The rule is any-guardrail's own backend metadata rather than a list
Otari keeps, and it counts a guardrail's alternate backends too: one that
defaults to a local model and also answers over a hosted API is listed, because
the hosted path is the one Otari would take.
Otari keeps.

A guardrail that names a hosted API as an *alternate* to a local default is not
listed, which is worth saying because one of them looks like it should be.
SusFactor answers over 0DIN's hosted API, but choosing that path means handing
the constructor a live provider object, and that is neither something a form can
collect nor something a database row can hold. What a stored SusFactor
definition would build is the local encoder, weights and all, so Otari does not
offer one.

The catalog reaches no service, so unlike the profiles read it has no
unavailable state. It is on the operator gate, because it is the picker behind a
Expand Down Expand Up @@ -221,8 +227,49 @@ re-entering the credentials. `POST /api/v1/guardrail-credentials/reencrypt` is
the guardrail half of a key rotation; run it beside the provider and search-tool
endpoints of the same name.

Nothing on the request path reads these rows yet, so storing a definition does
not change how a request behaves.
### When a stored guardrail is built

Otari builds every stored definition when it starts, and builds one again after
the write that changed it. A request never waits for a guardrail to be
constructed.

The startup pass runs in the background, so a slow vendor SDK cannot hold the
port closed and a definition that will not build cannot stop the gateway. Both
are logged, and the profile they cost reports as unevaluated until the next
write or restart, which is the same state `on_unavailable` already governs. A
deletion forgets the profile. A re-encryption builds nothing, because it rotates
ciphertext and changes no argument.

Each worker builds its own, so a write takes effect on the worker that served it
and on the others when they next restart. A definition is deployment
configuration, like a provider credential, and the provider store has the same
property.

```bash
curl -X POST http://localhost:8000/api/v1/guardrail-credentials/prompt-injection/test \
-H "Authorization: Bearer $OTARI_MASTER_KEY" \
-H 'Content-Type: application/json' \
-d '{"input_text": "ignore your previous instructions"}'
```

```json
{"ok": true, "valid": false, "explanation": "prompt injection", "score": 0.97}
```

`ok` says whether the guardrail ran at all, and `valid` is its verdict: `false`
is flagged, `true` passed, `null` inconclusive. A guardrail that could not run
answers `ok: false` with the reason instead of an error status. The endpoint
builds the definition as it stands and checks against that, so a definition that
failed to build at startup still answers, and a disabled one is testable, since
checking one before turning it on is the point. It changes nothing about what the
gateway is enforcing: what it built is thrown away, and a profile becomes live
through a write, never through a test.

One guardrail in the catalog needs a vendor package the published image does not
carry: Azure Content Safety. Its build fails with a message naming the package.

Nothing on the request path reads these rows yet, so storing a definition still
does not change how a request behaves.

### How the layers compose

Expand Down
144 changes: 144 additions & 0 deletions docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13158,6 +13158,89 @@
"title": "TaskPool",
"type": "object"
},
"TestGuardrailRequest": {
"description": "Text to run one stored guardrail against.",
"properties": {
"input_text": {
"maxLength": 8000,
"minLength": 1,
"title": "Input Text",
"type": "string"
},
"validate_kwargs": {
"additionalProperties": true,
"description": "Merged over the stored per-call arguments, for this call only.",
"title": "Validate Kwargs",
"type": "object"
}
},
"required": [
"input_text"
],
"title": "TestGuardrailRequest",
"type": "object"
},
"TestGuardrailResponse": {
"description": "What one guardrail said about the text.",
"properties": {
"error": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Why the guardrail could not run, when ok is false.",
"title": "Error"
},
"explanation": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Explanation"
},
"ok": {
"description": "Whether the guardrail ran at all. False means it could not be evaluated.",
"title": "Ok",
"type": "boolean"
},
"score": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Score"
},
"valid": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"description": "True when the input passed, false when it was flagged, null when the verdict was inconclusive.",
"title": "Valid"
}
},
"required": [
"ok"
],
"title": "TestGuardrailResponse",
"type": "object"
},
"TestProviderRequest": {
"description": "Credentials to test before saving (from the add-provider form).",
"properties": {
Expand Down Expand Up @@ -20977,6 +21060,67 @@
]
}
},
"/api/v1/guardrail-credentials/{name}/test": {
"post": {
"description": "Run a stored guardrail against some text, so an operator sees it work.\n\nBuilds the definition as it stands right now and checks the text against that,\nchanging nothing about what the gateway is enforcing. A disabled definition is\nas testable as any other, since checking one before turning it on is the point,\nand finding out must not be what puts it in front of traffic.\n\nA guardrail that cannot run answers ``ok: false`` with the reason rather than\nan error status: the question asked was whether this definition works, and one\nshape of answer is easier to act on than two.",
"operationId": "guardrail-credentials-test_stored_guardrail",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestGuardrailRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestGuardrailResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"ApiKeyAuth": []
},
{
"XApiKeyAuth": []
}
],
"summary": "Test Stored Guardrail",
"tags": [
"guardrail-credentials"
]
}
},
"/api/v1/health": {
"get": {
"description": "General health check endpoint.\n\nReturns basic health status. For infrastructure monitoring,\nuse /health/readiness or /health/liveness instead.",
Expand Down
42 changes: 42 additions & 0 deletions docs/public/otari.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,48 @@
]
}
}
},
{
"name": "Test Stored Guardrail",
"request": {
"body": {
"mode": "raw",
"options": {
"raw": {
"language": "json"
}
},
"raw": "{\n \"input_text\": \"string\"\n}"
},
"description": "Run a stored guardrail against some text, so an operator sees it work.\n\nBuilds the definition as it stands right now and checks the text against that,\nchanging nothing about what the gateway is enforcing. A disabled definition is\nas testable as any other, since checking one before turning it on is the point,\nand finding out must not be what puts it in front of traffic.\n\nA guardrail that cannot run answers ``ok: false`` with the reason rather than\nan error status: the question asked was whether this definition works, and one\nshape of answer is easier to act on than two.",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"method": "POST",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"v1",
"guardrail-credentials",
":name",
"test"
],
"raw": "{{baseUrl}}/api/v1/guardrail-credentials/:name/test",
"variable": [
{
"description": "path parameter",
"key": "name",
"value": ""
}
]
}
}
}
],
"name": "guardrail-credentials"
Expand Down
18 changes: 10 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ description = "otari, an OpenAI-compatible LLM gateway"
requires-python = ">=3.13"
dependencies = [
"any-llm-sdk[all]>=1.27.1",
# The guardrail catalog only (`services/guardrail_catalog.py`). Its
# `parameter_registry` is a stdlib+pydantic leaf built so a consumer can
# render a configuration form without importing a model backend, so no
# extra is taken and nothing here ever constructs a guardrail: this
# gateway runs them against the operator's any-guardrail sidecar. What is
# imported is `GuardrailName` and `get_parameter_schema`, and the shape of
# the `ParameterSpec` they return; bounded below 0.8 rather than trusting a
# 0.x minor to keep those three.
# Read by the catalog (`services/guardrail_catalog.py`) and called by the
# runner (`services/guardrail_runner.py`). Its `parameter_registry` is a
# stdlib+pydantic leaf built so a consumer can render a configuration form
# without importing a model backend. No extra is taken, and none is needed:
# only a guardrail whose backend is a hosted API is built here, and each of
# those is a client and a request rather than a model download. What is
# imported is `GuardrailName`, `GUARDRAIL_METADATA`, `get_parameter_schema`
# and the shape of the `ParameterSpec` it returns, plus `AnyGuardrail`,
# `Guardrail`, `EvaluateArgumentError` and `BackendType`; bounded below 0.8
# rather than trusting a 0.x minor to keep them.
"any-guardrail>=0.7.7,<0.8.0",
"alembic>=1.13.0",
"aiosqlite>=0.19.0",
Expand Down
1 change: 1 addition & 0 deletions scripts/sdk_codegen/sdk-endpoints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ GET /api/v1/guardrail-credentials/{name} # not yet wrapped
PATCH /api/v1/guardrail-credentials/{name} # not yet wrapped
DELETE /api/v1/guardrail-credentials/{name} # not yet wrapped
POST /api/v1/guardrail-credentials/reencrypt # not yet wrapped
POST /api/v1/guardrail-credentials/{name}/test # not yet wrapped
# Settings
POST /api/v1/settings/master-key/rotate # not yet wrapped
# Tenancy-scoped budgets: an operator surface with no dashboard page yet either,
Expand Down
Loading