mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(guardrails): straiker guardrail speaks the v3 platform API (/api/v3/detect) (#41880)
* feat(guardrails): speak the Straiker v3 platform API (/api/v3/detect)
The Straiker guardrail posted a webhook envelope to /api/v1/detect/webhook.
The v3 platform exposes /api/v3/detect instead, and its integration keys
(sk_agt_…) are rejected by the v1 route with an empty 401, so a tenant on
the v3 platform could not run this guardrail at all. Measured on a
customer gateway on 2026-09-17 after they rotated to a v3 key.
v3 parses the gateway's own traffic server-side, the same contract as
Straiker's unified Kong plugin. So on v3 the guardrail relays: the
request phase posts the provider body LiteLLM received (Anthropic
Messages or OpenAI chat), the response phase posts
{straiker_phase, sse, model, request}, the answer beside the request it
answers, and Straiker derives prompt, answer, agent and archetype. Both
phases also carry the flat prompt / app_response pair: a gateway-mode
integration key scores only the flat pair and an api-mode key only the
relayed body, each ignoring the other, so one payload serves whichever
key the console issued and it is one turn either way (measured on tenant
123, both key modes, 2026-09-18).
- api_version: "v1" | "v3", unset follows the key prefix, so a v3 key
needs no extra configuration. Explicit override still wins.
- The relayed body is an allowlist of provider fields. The hook sees the
client body merged with proxy state: `deployment` carries the resolved
provider credential and `proxy_server_request` the client's own
Authorization header. Neither travels. Identity survives as the
metadata subset Straiker's LiteLLM adapter reads.
- Identity never sends a proxy placeholder. `default_user_id` and the
master-key alias were being forwarded as a user and became the
session's identity on the platform.
- Headers: x-tool: litellm (ingress), x-straiker-phase, x-straiker-user,
and x-claude-code-session-id forwarded when the client sent it.
- Verdict: hookSpecificOutput.permissionDecision on the gateway envelope,
`action` on the flat one; block on block/deny, and on a non-empty
blocked_by as a backstop. A detect-mode control reads NONE.
- An error status from Straiker is now a webhook failure. LiteLLM's HTTP
client raises on any non-2xx and the retry loop caught only connection
errors, so a 401 or 503 from Straiker escaped the guardrail as an
exception and was relayed raw to the client, bypassing fail_open /
fail_closed. Retryable statuses retry; the rest are final.
- v1 is unchanged: same envelope, same X-Straiker-Webhook-Format header.
Tests: 15 new, fixtures from the request dict a hook sees on 1.98.0 and
the verdict envelopes the v3 platform returned on 2026-09-18. Each fix
was mutation-checked (handling removed, the test fails). Live: the same
eight-case battery (chat, /v1/messages, streaming, tool call; benign,
injection, PII) passes on a gateway-mode and an api-mode key, blocks at
pre_call with the tenant's block message, and lands under the declared
agent with the end user attributed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(guardrails): name the agent per application on v3 (x-s6r-agent)
One integration key can front several applications. Straiker enumerates them
as separate agents when the turn names one, which is what the unified Kong
plugin sends as x-s6r-agent. Without it every application on a gateway
collapses onto a single agent.
- Forwards a client-supplied x-s6r-agent.
- New `agent_ref` config names one agent for a route when the client sends
nothing. The client wins, matching Kong's precedence.
- Neither set: no header, and the platform derives the agent from the traffic.
Verified live on tenant 123 against an integration whose connector is
`gateway`: three distinct values minted three observed agents, and a turn
with no hint derived one from the traffic shape. An integration whose
connector is `custom-agent` declares its agent, so every turn attributes to
that one agent and the hint is ignored (agent_ref_source: attested).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(guardrails): which v3 shape is scored depends on the connector, not the key mode
The earlier comment said a gateway-mode key scores only the flat pair. Re-measured
on tenant 123 across all three integration types with one injection prompt:
custom-agent connector (Add Agent) raw body ignored flat prompt scored
gateway connector raw body scored flat prompt scored
api mode raw body scored flat prompt ignored
Behaviour unchanged: the payload already carries both shapes, which is why it works
on every type. Comment only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(guardrails): send exactly what the unified Kong plugin sends on v3
The v3 platform parses the gateway's traffic itself and derives agent,
archetype and identity from it. The earlier commits added to the relayed
body (a flat prompt / app_response pair, source, user_name) and to the
headers (x-tool, x-straiker-phase, x-straiker-user). None of that is in
the Kong v0.12 contract, and traffic through this guardrail was not
classifying by shape the way the same traffic through Kong does. Match
Kong byte for byte and leave classification to the platform.
Request phase: the provider body, plus session_id and
original.processed.Meta.user. Response phase: {straiker_phase, sse,
model, request} plus the same two. No flat fields, no phase or user
headers, no x-tool.
Session id follows Kong's precedence: the client's x-claude-code-session-id,
then the session LiteLLM resolved, then an md5 of system prompt + first
message so a conversation that states no session still groups across its
replays.
Routing hints complete the Kong set: x-s6r-agent (client header, else
`agent_ref`), and new `client` (x-s6r-client) and `format_hint`
(x-s6r-format) config, both optional.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(guardrails): sort imports in the v3 session test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): send a streamed Messages answer back in the Messages shape on v3
On a streamed /v1/messages call the proxy rebuilds the answer as a chat
completion before the post-call hook runs, and that is what the plugin put in
the response envelope's sse field. Straiker's coding-agent reader parses a
Messages answer, so a Claude Code turn relayed this way came back
coding_agent/claude with no session and zero events scored: the model's tool
calls were never screened on the response phase. Captured live on 2026-09-18
against tenant 123, a real Claude Code Bash tool call through the proxy.
The proxy's own Anthropic adapter turns the rebuilt answer back into a Messages
response when the call arrived on the anthropic_messages route, which is what a
transport relay forwards. Chat completions calls keep the chat completion shape
and a buffered Messages answer is relayed untouched.
The regression test's fixture is the chat completion the proxy actually built
for that captured turn. After the fix the same turn scores on the response
phase (session resolved, one event, the Bash tool_use block present).
* style(straiker): ruff format the v3 guardrail and its tests
* refactor(straiker): one attempt per call in the webhook retry loop
The HTTPStatusError branch added for v3 duplicated the non-200 branch and put
_post_webhook over the strict complexity ceiling. One attempt is now its own
method that returns the verdict or a failure marked retryable, and the loop only
decides whether to try again. Behaviour is unchanged: retryable statuses and
transport errors retry, everything else is final.
* fix(straiker): name Claude Code's client and agent on v3 so its session lands under one coding agent
Straiker types a gateway turn as a coding agent from the "You are Claude Code"
preamble, which only the main agent turns carry. Claude Code's title and
topic-detection sidecars have their own system prompts, so they resolved by
shape as autonomous, and because they share the session id with the main turns
the whole session was filed under Autonomous rather than under a coding agent.
Kong does not hit this because its plugin config names the client and agent on
every call.
The User-Agent (claude-cli/...) is on every call including the sidecars, so the
plugin now reads it and sends x-s6r-client: claude plus, when the route names no
agent, x-s6r-agent: "Claude (LiteLLM)". A client-supplied x-s6r-agent or the
agent_ref config still wins. Verified live on tenant 123: a real Claude Code
session now lands as one coding_agent labelled "Claude (LiteLLM)" with its turns
scored, where before it split across Autonomous.
Identity: the key's own user (email then id) now outranks the end user the
request named. LiteLLM resolves Claude Code's hashed metadata.user_id as the end
user when nothing better is set, so a per-user key was being shadowed by a
session token. The key is the authenticated principal, the way a Kong consumer
is, so it wins; the request end user is the fallback.
* refactor(straiker): build the v3 request, envelope and headers as frozen mappings
The v3 builders seeded dicts and grew them, which the type-discipline gate
counts as mutable accumulators. Each is now one expression over a tuple of
pairs, frozen with MappingProxyType, and the JSON encoder unwraps a frozen
mapping through a default. The session seed and the verdict parser no longer
rebind locals. The wire is unchanged: 36 live calls through the proxy on this
commit carry the same fields, shapes, headers and identities as before, with
no mappingproxy text in any body.
* fix(straiker): satisfy basedpyright on the v3 builders
The frozen-mapping refactor left a shadowed headers local, a Mapping handed to
an HTTP client that takes a dict, an unguarded optional response, a turn id
typed object, and a redundant isinstance on already-typed texts. No behaviour
change: 4 live calls (chat, Messages, Bedrock, injection) return 200 with the
expected verdicts on this commit.
* fix(straiker): type the v3 config fields at the initializer and keep the verbose log as JSON
The four v3 routing fields (api_version, agent_ref, client, format_hint)
travelled through the untyped kwargs passthrough, which basedpyright counts
against the budget. They are now validated through a small Pydantic model at
the initializer and passed by name.
The verbose log serialized the frozen payload with default=str, which printed
a Python repr instead of JSON once the builders returned MappingProxyType.
Every serializer now unwraps a frozen mapping first. A test asserts the logged
payload parses as JSON and carries the identity; mutating the log site back to
default=str fails it.
* fix(straiker): address review findings on the v3 relay
Text completions relay their prompt: `prompt`, `suffix`, `echo` and `best_of`
join the provider allowlist, so /v1/completions traffic is screened.
The route's `agent_ref` now outranks the caller's `x-s6r-agent` header. The
header is caller-supplied, and letting it beat a pinned route would let any key
file its traffic under another application's agent and controls. On a route
that names nothing the header still names the application, which is how
several applications enumerate behind one key.
Credentials inside `tools` and `mcp_servers` (an OpenAI `mcp` tool's `headers`,
Anthropic's `authorization_token`) are replaced with `[redacted]` before the
body leaves the proxy, on both phases and in the verbose log. Detection reads
tool names, descriptions and schemas, never these.
A 200 whose body is valid JSON but not an object now reports an invalid
schema and follows the failure policy instead of raising out of the hook.
Comments that restated a constant are gone. Tests cover each change and the
failure paths (unreadable error body, client exceptions, missing response,
unmodellable request, session seeds from Anthropic block shapes); every fix
fails its test when reverted.
* fix(straiker): scrub tool credentials one level deep, without recursion
* fix(straiker): scrub only the fields that carry a credential, never a schema
The credential set is now the three fields that actually hold one on a tools
or mcp_servers entry (headers, authorization, authorization_token), read one
level deep. A function tool whose parameter schema defines a token, headers or
api_key property is relayed exactly as sent; a test pins that, and fails
against the recursive version.
* test(straiker): use example.com identities; drop a comment that restated its branch
* fix(straiker): present a legacy completion as the chat exchange it is
Straiker scores chat on both phases of a gateway turn but has no reader for a
text_completion answer: the request phase of a /v1/completions call was
scored and the response phase was refused with 501, whether or not the call
named an agent. A completion is one user turn and one assistant turn, so both
phases now present that exchange: the prompt becomes the single user message
and the TextCompletionResponse becomes a chat completion. Measured through the
proxy on this commit, both phases return 200 and score, and the derived
session is shared between them.
The derived session seed accepts the tuple the conversion produces; the test
pins the session on both phases and fails against the list-only check. The
unreachable "parsed is None" branch is folded into the failure branch, and a
malformed tools value is shown to relay as sent.
* fix(straiker): screen a completions prompt as the text the model receives
LiteLLM's /v1/completions accepts a string, a list of strings, a list of
token ids or a list of token-id lists, and decodes token ids with the
text-davinci-003 tokenizer before calling the model. The relay now renders
the prompt the same way, one user message per prompt, so a pre-tokenized
prompt is screened as the text it stands for rather than as digit strings.
A prompt in a shape this cannot render (empty, mixed, or with no tokenizer
available) is relayed untouched instead of being replaced with something
else. Tests cover all four accepted shapes and six unrenderable ones.
* fix(straiker): seed the derived session on the preamble and the first user turn
An OpenAI chat body carries its system prompt as messages[0], and the derived
session seeded on the Anthropic `system` field plus messages[0] with no role
check. For that shape the seed was the system prompt twice and the first user
turn never counted, so every unnamed conversation behind one system prompt
collapsed into one Straiker session. The seed now takes the preamble from
wherever the API puts it (`system`, `instructions`, or a leading system or
developer message) and the first message with role `user`, else a Responses
`input` string, else `prompt`. Two conversations sharing a system prompt are
two sessions again; a replayed conversation stays one.
* fix(straiker): seed the derived session on every text block of the first turn
A user turn that opens with an image or a document block and carries its
text later seeded the session on an empty string, so two different
conversations under the same preamble shared one Straiker session. Read
every text block of the turn instead of only the first block. A plain
string or a single text block seeds exactly as before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(straiker): cover the tokenizer fallback, a textless first turn and Responses instructions
Three branches of the v3 relay had no test: a token-id prompt relayed as
sent when the tokenizer cannot be fetched, a first user turn with no text
seeding the session on the preamble alone, and a Responses API body
seeding on its instructions and first input turn. Each test fails when
its branch is mutated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): seed the derived session on the principal as well as the conversation
Straiker skips turns it has already scored for a session. The derived
session hashed the system prompt and the first user turn alone, so two
users who opened a conversation with the same words shared one session,
and the second user's copy of an attack came back as a replay: unscored
and allowed. Measured live on 2026-09-20: the first user's SSN turn was
blocked (`social_security_number`, scored=2), the second user's identical
turn was allowed (`controls: []`, replayed=2).
The principal now joins the seed. Explicit session ids, the Claude Code
header and LiteLLM's own session are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): derive the session id with sha256 and drop comments that restated constants
The derived session now hashes the principal, and CodeQL flags MD5 over an
identity as a weak hash on sensitive data. SHA-256 truncated to the same
32 hex characters keeps the id shape. Comments that only labelled the
allowlist groups or restated a constant are removed; the two that explain
a non-obvious choice stay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): keep a blocked conversation blocked when it is replayed
Straiker de-duplicates turns it has already scored per session and
answers a replay `allow`, whatever the first verdict was. A client that
resends a blocked request, or grows the conversation past the blocked
turn, was let through: measured on 2026-09-20, `block` then `allow,
events_replayed=2` for the same session and body, and Claude Code's
automatic retry after the 400 turned a blocked poisoned-file read into
a pass.
The guardrail now remembers, per session, a fingerprint of every
conversation it blocked (a bounded, day-long in-memory cache) and blocks
a request that repeats or extends one without asking again. A different
session with the same words is a new conversation and is scored afresh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): scope the block memory by session or principal, never by content alone
A request with no derivable session keyed the replay memory on the
conversation fingerprint alone, so one caller's block could answer
another caller's identical request. The memory is now scoped by the
session, else by the principal, and a request with neither is not
remembered at all.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(straiker): remember only a block that names a control, never one that comes from state
The replay memory kept every block, including one the platform returns
because a kill switch is engaged (`action: block` with `blocked_by: []`).
An administrator lifting the kill switch then left the conversation
refused by the remembered copy: measured on 2026-09-21, traffic stayed
blocked after `POST /inventory/agents/{id}/restore` returned `engaged:
false`.
The same words are the same attack tomorrow, so a control-named block is
still worth remembering; state is not ours to cache. The parsed verdict
now carries `blocked_by` so the two can be told apart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Phimmasone Phonpaseuth <PhimmStraiker@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
59eb943a6e
commit
e0af9917a1
4 changed files with 2145 additions and 44 deletions
|
|
@ -1,4 +1,6 @@
|
|||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import litellm
|
||||
from litellm.types.guardrails import SupportedGuardrailIntegrations
|
||||
|
|
@ -8,6 +10,14 @@ from .straiker import StraikerGuardrail
|
|||
if TYPE_CHECKING:
|
||||
from litellm.types.guardrails import Guardrail, LitellmParams
|
||||
|
||||
|
||||
class _V3Routing(BaseModel):
|
||||
api_version: Literal["v1", "v3"] | None = None
|
||||
agent_ref: str | None = None
|
||||
client: str | None = None
|
||||
format_hint: Literal["anthropic.messages", "openai.chat"] | None = None
|
||||
|
||||
|
||||
_OPTIONAL_INIT_FIELDS: Final = (
|
||||
"timeout",
|
||||
"max_retries",
|
||||
|
|
@ -48,6 +58,12 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
|
|||
for value in [_get_config_value(litellm_params, optional_params, field)]
|
||||
if value is not None
|
||||
}
|
||||
routing: Final = _V3Routing.model_validate(
|
||||
{
|
||||
field: _get_config_value(litellm_params, optional_params, field)
|
||||
for field in ("api_version", "agent_ref", "client", "format_hint")
|
||||
}
|
||||
)
|
||||
_callback: Final = StraikerGuardrail(
|
||||
api_key=api_key,
|
||||
api_base=api_base if isinstance(api_base, str) else "https://api.prod.straiker.ai",
|
||||
|
|
@ -55,6 +71,10 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail"
|
|||
guardrail_name=guardrail.get("guardrail_name", "straiker"),
|
||||
event_hook=litellm_params.mode,
|
||||
default_on=litellm_params.default_on,
|
||||
api_version=routing.api_version,
|
||||
agent_ref=routing.agent_ref,
|
||||
client=routing.client,
|
||||
format_hint=routing.format_hint,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -12,6 +15,7 @@ from pydantic import BaseModel, TypeAdapter, ValidationError
|
|||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._version import version as litellm_version
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.exceptions import (
|
||||
BadRequestError,
|
||||
GuardrailRaisedException,
|
||||
|
|
@ -29,6 +33,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._types import SpecialProxyStrings
|
||||
from litellm.types.guardrails import GuardrailEventHooks, Mode
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.straiker import (
|
||||
STRAIKER_WEBHOOK_SCHEMA_VERSION,
|
||||
|
|
@ -43,7 +48,7 @@ from litellm.types.proxy.guardrails.guardrail_hooks.straiker import (
|
|||
StraikerWebhookStream,
|
||||
StraikerWebhookUsage,
|
||||
)
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs
|
||||
from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs, ModelResponse, TextCompletionResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -54,6 +59,93 @@ DEFAULT_BLOCK_MESSAGE: Final = "Content violates policy"
|
|||
DEFAULT_API_BASE: Final = "https://api.prod.straiker.ai"
|
||||
DEFAULT_MAX_PAYLOAD_BYTES: Final = 524288
|
||||
WEBHOOK_PATH: Final = "/api/v1/detect/webhook"
|
||||
V3_DETECT_PATH: Final = "/api/v3/detect"
|
||||
V3_KEY_PREFIX: Final = "sk_agt_"
|
||||
V3_SESSION_HEADER: Final = "x-claude-code-session-id"
|
||||
V3_CLIENT_HEADER: Final = "x-s6r-client"
|
||||
V3_FORMAT_HEADER: Final = "x-s6r-format"
|
||||
# (User-Agent prefix, Straiker client value, display name). Straiker recognises a coding agent
|
||||
# from the system prompt of its main turns only; Claude Code's title and topic sidecars carry
|
||||
# other prompts and would split the session across two agents. The User-Agent is on every call.
|
||||
_V3_CLIENT_BY_USER_AGENT: Final = (("claude-cli/", "claude", "Claude"),)
|
||||
V3_GATEWAY_NAME: Final = "LiteLLM"
|
||||
V3_DERIVED_SESSION_PREFIX: Final = "litellm-"
|
||||
V3_AGENT_HEADER: Final = "x-s6r-agent"
|
||||
V3_RESPONSE_PHASE: Final = "response-sync"
|
||||
V3_BLOCK_DECISIONS: Final = frozenset({"block", "deny"})
|
||||
V3_BLOCKED_TURN_MEMORY: Final = 10_000
|
||||
V3_BLOCKED_TURN_TTL_SECONDS: Final = 24 * 60 * 60
|
||||
# An allowlist: the hook's request dict merges the client body with proxy state (`deployment`
|
||||
# carries the resolved credential), so only fields named here are relayed.
|
||||
_V3_PROVIDER_BODY_KEYS: Final = frozenset(
|
||||
{
|
||||
"model",
|
||||
"messages",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"functions",
|
||||
"function_call",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"n",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"stop",
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"logit_bias",
|
||||
"user",
|
||||
"response_format",
|
||||
"seed",
|
||||
"logprobs",
|
||||
"top_logprobs",
|
||||
"parallel_tool_calls",
|
||||
"reasoning_effort",
|
||||
"modalities",
|
||||
"audio",
|
||||
"prediction",
|
||||
"store",
|
||||
"service_tier",
|
||||
"web_search_options",
|
||||
"prompt",
|
||||
"suffix",
|
||||
"echo",
|
||||
"best_of",
|
||||
"system",
|
||||
"stop_sequences",
|
||||
"top_k",
|
||||
"thinking",
|
||||
"container",
|
||||
"mcp_servers",
|
||||
"context_management",
|
||||
"output_format",
|
||||
"input",
|
||||
"instructions",
|
||||
"previous_response_id",
|
||||
"truncation",
|
||||
"text",
|
||||
"include",
|
||||
"reasoning",
|
||||
"max_output_tokens",
|
||||
"background",
|
||||
"conversation",
|
||||
"session_id",
|
||||
}
|
||||
)
|
||||
# The scrub of these is one level deep on purpose: a function schema that defines a `token` or
|
||||
# `headers` property lives under `function.parameters` and must be relayed as sent.
|
||||
_V3_CREDENTIAL_FIELDS: Final = frozenset({"authorization_token", "authorization", "headers"})
|
||||
_V3_REDACTED_VALUE: Final = "[redacted]"
|
||||
_V3_REDACTED_KEYS: Final = frozenset({"tools", "mcp_servers"})
|
||||
_V3_IDENTITY_METADATA_KEYS: Final = (
|
||||
"user_api_key_end_user_id",
|
||||
"user_api_key_user_email",
|
||||
"user_api_key_user_id",
|
||||
"user_api_key_alias",
|
||||
"user_api_key_team_id",
|
||||
)
|
||||
RETRY_STATUS: Final = frozenset({408, 429, 500, 502, 503, 504})
|
||||
UNREACHABLE_STATUS: Final = frozenset({502, 503, 504})
|
||||
_APPLICATION_METADATA_KEYS: Final = frozenset({"agent_id", "app_name"})
|
||||
|
|
@ -65,13 +157,29 @@ _JSON_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
|
|||
class _WebhookFailure:
|
||||
message: str
|
||||
is_unreachable: bool
|
||||
retryable: bool = False
|
||||
|
||||
|
||||
def _status_failure(status: int, text: str) -> _WebhookFailure:
|
||||
return _WebhookFailure(
|
||||
f"HTTP {status}: {text[:200]}",
|
||||
is_unreachable=status in UNREACHABLE_STATUS,
|
||||
retryable=status in RETRY_STATUS,
|
||||
)
|
||||
|
||||
|
||||
def _error_response_text(response: httpx.Response) -> str:
|
||||
try:
|
||||
return response.text
|
||||
except Exception: # noqa: BLE001 # a masked response may carry no body
|
||||
return ""
|
||||
|
||||
|
||||
def _as_dict(value: object) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _merged_metadata(request_data: dict) -> dict:
|
||||
def _merged_metadata(request_data: Mapping[str, object]) -> dict:
|
||||
return {
|
||||
**_as_dict(request_data.get("metadata")),
|
||||
**_as_dict(request_data.get("litellm_metadata")),
|
||||
|
|
@ -268,6 +376,478 @@ def _is_streamed_request(request_data: dict) -> bool:
|
|||
return body.get("stream") is True
|
||||
|
||||
|
||||
# What the proxy stamps on a master-key call in place of a person. Sent onward, either
|
||||
# would be recorded as an identity and every master-key turn filed under it.
|
||||
_PLACEHOLDER_IDENTITIES: Final = frozenset({SpecialProxyStrings.default_user_id.value, "litellm_proxy_master_key"})
|
||||
|
||||
|
||||
def _real_identity(value: object) -> str | None:
|
||||
"""LiteLLM's proxy-admin placeholders are not a person."""
|
||||
identity: Final = _as_optional_str(value)
|
||||
return None if identity in _PLACEHOLDER_IDENTITIES else identity
|
||||
|
||||
|
||||
def _request_header(request_data: Mapping[str, object], name: str | None) -> str | None:
|
||||
"""A header from the inbound request, when LiteLLM kept it on the request data."""
|
||||
if not name:
|
||||
return None
|
||||
proxy_request: Final = request_data.get("proxy_server_request")
|
||||
headers: Final = proxy_request.get("headers") if isinstance(proxy_request, Mapping) else None
|
||||
if not isinstance(headers, Mapping):
|
||||
return None
|
||||
wanted: Final = name.lower()
|
||||
for key, value in headers.items():
|
||||
if str(key).lower() == wanted and isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _frozen(pairs: Iterable[tuple[str, object]]) -> Mapping[str, object]:
|
||||
return MappingProxyType(dict(pairs))
|
||||
|
||||
|
||||
def _json_default(value: object) -> object:
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value) # mutable-ok: the JSON encoder needs a dict view of a frozen mapping
|
||||
return str(value)
|
||||
|
||||
|
||||
def _v3_identity_metadata(request_data: Mapping[str, object]) -> Mapping[str, str]:
|
||||
"""The proxy-resolved identity fields, and only those, for the relayed body."""
|
||||
merged: Final = _merged_metadata(request_data)
|
||||
return MappingProxyType(
|
||||
{key: value for key in _V3_IDENTITY_METADATA_KEYS if (value := _real_identity(merged.get(key)))}
|
||||
)
|
||||
|
||||
|
||||
def _v3_request_body(request_data: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""The provider body LiteLLM received, stripped of everything the proxy added.
|
||||
|
||||
The hook sees the client's request merged with proxy bookkeeping: logging objects,
|
||||
the resolved key, the inbound headers. Only the provider body is Straiker's to read,
|
||||
and the client's Authorization header must not travel. Identity survives as the
|
||||
metadata subset the Straiker LiteLLM adapter reads.
|
||||
"""
|
||||
identity: Final = _v3_identity_metadata(request_data)
|
||||
turns: Final = (
|
||||
_v3_prompt_as_messages(request_data.get("prompt"))
|
||||
if _v3_text_completion_route(request_data) and "messages" not in request_data
|
||||
else None
|
||||
)
|
||||
provider: Final = (
|
||||
(key, _v3_without_credentials(value) if key in _V3_REDACTED_KEYS else value)
|
||||
for key, value in request_data.items()
|
||||
if key in _V3_PROVIDER_BODY_KEYS and not (turns is not None and key == "prompt")
|
||||
)
|
||||
prompt_turns: Final = (("messages", turns),) if turns is not None else ()
|
||||
return _frozen((*provider, *prompt_turns, *((("metadata", identity),) if identity else ())))
|
||||
|
||||
|
||||
def _v3_without_credentials(entries: object) -> object:
|
||||
if not isinstance(entries, (list, tuple)):
|
||||
return entries
|
||||
return tuple(
|
||||
_frozen(
|
||||
(str(key), _V3_REDACTED_VALUE if str(key).lower() in _V3_CREDENTIAL_FIELDS else item)
|
||||
for key, item in entry.items()
|
||||
)
|
||||
if isinstance(entry, Mapping)
|
||||
else entry
|
||||
for entry in entries
|
||||
)
|
||||
|
||||
|
||||
def _v3_route_is(request_data: Mapping[str, object], call_type: CallTypes) -> bool:
|
||||
from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
|
||||
|
||||
route: Final = _merged_metadata(request_data).get("user_api_key_request_route")
|
||||
if not isinstance(route, str) or not route:
|
||||
return False
|
||||
return call_type in (get_call_types_for_route(route) or ())
|
||||
|
||||
|
||||
def _v3_anthropic_messages_route(request_data: Mapping[str, object]) -> bool:
|
||||
return _v3_route_is(request_data, CallTypes.anthropic_messages)
|
||||
|
||||
|
||||
def _v3_text_completion_route(request_data: Mapping[str, object]) -> bool:
|
||||
return _v3_route_is(request_data, CallTypes.text_completion)
|
||||
|
||||
|
||||
def _v3_is_token_list(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (list, tuple))
|
||||
and bool(value)
|
||||
and all(isinstance(token, int) and not isinstance(token, bool) for token in value)
|
||||
)
|
||||
|
||||
|
||||
def _v3_decode_tokens(tokens: Iterable[object]) -> str | None:
|
||||
ids: Final = [token for token in tokens if isinstance(token, int)] # mutable-ok: tiktoken decodes a list
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
return tiktoken.encoding_for_model("text-davinci-003").decode(ids)
|
||||
except Exception: # noqa: BLE001 # no tokenizer available: the raw prompt is relayed instead
|
||||
return None
|
||||
|
||||
|
||||
def _v3_prompt_texts(prompt: object) -> tuple[str, ...] | None:
|
||||
"""The text the model receives for a completions `prompt`, in the proxy's own terms.
|
||||
|
||||
LiteLLM accepts a string, a list of strings, a list of token ids, or a list of token-id
|
||||
lists, and decodes token ids with the text-davinci-003 tokenizer before calling the model.
|
||||
The same decoding here means Straiker screens what the model gets. None when the prompt
|
||||
is a shape this cannot render, so the caller relays it untouched rather than screening
|
||||
something else.
|
||||
"""
|
||||
if isinstance(prompt, str):
|
||||
return (prompt,)
|
||||
if not isinstance(prompt, (list, tuple)) or not prompt:
|
||||
return None
|
||||
if all(isinstance(item, str) for item in prompt):
|
||||
return tuple(str(item) for item in prompt)
|
||||
if _v3_is_token_list(prompt):
|
||||
decoded: Final = _v3_decode_tokens(prompt)
|
||||
return (decoded,) if decoded is not None else None
|
||||
if all(_v3_is_token_list(item) for item in prompt):
|
||||
decoded_each: Final = tuple(_v3_decode_tokens(item) for item in prompt)
|
||||
return None if any(text is None for text in decoded_each) else tuple(text or "" for text in decoded_each)
|
||||
return None
|
||||
|
||||
|
||||
def _v3_prompt_as_messages(prompt: object) -> tuple[Mapping[str, object], ...] | None:
|
||||
texts: Final = _v3_prompt_texts(prompt)
|
||||
if texts is None:
|
||||
return None
|
||||
return tuple(_frozen((("role", "user"), ("content", text))) for text in texts)
|
||||
|
||||
|
||||
def _v3_answer(request_data: Mapping[str, object], model: str | None) -> Mapping[str, object] | None:
|
||||
"""The answer in the API shape the client spoke, which is what a relay forwards.
|
||||
|
||||
On a streamed Messages call the proxy rebuilds the answer as a chat completion before
|
||||
the hook runs. Straiker's coding-agent reader parses a Messages answer, so a Claude Code
|
||||
turn sent as a chat completion scores nothing; the proxy's own adapter turns it back.
|
||||
"""
|
||||
response: Final = request_data.get("response")
|
||||
if isinstance(response, TextCompletionResponse):
|
||||
return _v3_text_completion_as_chat(response)
|
||||
if not isinstance(response, ModelResponse) or not _v3_anthropic_messages_route(request_data):
|
||||
return _jsonable_dict(response)
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
|
||||
LiteLLMAnthropicMessagesAdapter,
|
||||
)
|
||||
|
||||
translated: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response=response)
|
||||
re_keyed: Final = dict(translated, model=response.model or model) # mutable-ok: adapter TypedDict re-keyed
|
||||
return _jsonable_dict(re_keyed)
|
||||
|
||||
|
||||
def _v3_text_completion_as_chat(response: TextCompletionResponse) -> Mapping[str, object]:
|
||||
"""A legacy completion answer in the chat shape the platform scores.
|
||||
|
||||
Straiker has no reader for a `text_completion` answer on a gateway: the request phase
|
||||
of a /v1/completions call is scored, the response phase is refused. A completion is one
|
||||
user turn and one assistant turn, so both phases are presented as that exchange.
|
||||
"""
|
||||
choices: Final = tuple(
|
||||
_frozen(
|
||||
(
|
||||
("index", index),
|
||||
("finish_reason", getattr(choice, "finish_reason", None)),
|
||||
("message", _frozen((("role", "assistant"), ("content", getattr(choice, "text", "") or "")))),
|
||||
)
|
||||
)
|
||||
for index, choice in enumerate(response.choices)
|
||||
)
|
||||
usage: Final = _jsonable_dict(getattr(response, "usage", None))
|
||||
return _frozen(
|
||||
(
|
||||
("id", response.id),
|
||||
("object", "chat.completion"),
|
||||
("created", response.created),
|
||||
("model", response.model),
|
||||
("choices", choices),
|
||||
*((("usage", usage),) if usage else ()),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _v3_answer_json(
|
||||
inputs: GenericGuardrailAPIInputs, request_data: Mapping[str, object], model: str | None
|
||||
) -> str | None:
|
||||
"""The model's answer as the raw response body Straiker parses on the response phase.
|
||||
|
||||
The real response object carries tool calls, which a coding-agent turn is scored on,
|
||||
so it is preferred. A streamed answer reaches the hook already assembled into texts,
|
||||
and those become a minimal chat completion so the answer is still scored.
|
||||
"""
|
||||
response: Final = _v3_answer(request_data, model)
|
||||
if response:
|
||||
return json.dumps(response, default=_json_default)
|
||||
texts: Final = tuple(t for t in (inputs.get("texts") or []) if t)
|
||||
if not texts:
|
||||
return None
|
||||
message: Final = _frozen((("role", "assistant"), ("content", "\n".join(texts))))
|
||||
choice: Final = _frozen((("index", 0), ("finish_reason", "stop"), ("message", message)))
|
||||
return json.dumps(_frozen((("object", "chat.completion"), ("choices", (choice,)))), default=_json_default)
|
||||
|
||||
|
||||
def _v3_payload(
|
||||
envelope: StraikerWebhookRequest,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: Mapping[str, object],
|
||||
input_type: Literal["request", "response"],
|
||||
) -> Mapping[str, object]:
|
||||
"""The /api/v3/detect body for one phase of a turn, the unified Kong plugin's contract.
|
||||
|
||||
Request phase: the provider body itself. Response phase: the answer beside the request
|
||||
it answers, `{straiker_phase, sse, model, request}`, which is how Straiker classifies a
|
||||
tool call the model just made. Straiker parses either and derives prompt, answer, agent
|
||||
and archetype from the traffic; nothing is pre-digested here. Identity and session ride
|
||||
on both phases the way Kong sends them.
|
||||
"""
|
||||
context: Final = envelope.context
|
||||
request_body: Final = _v3_request_body(request_data)
|
||||
answer_json: Final = _v3_answer_json(inputs, request_data, context.model) if input_type == "response" else None
|
||||
phase: Final = (
|
||||
tuple(request_body.items())
|
||||
if input_type == "request"
|
||||
else (
|
||||
("straiker_phase", V3_RESPONSE_PHASE),
|
||||
("model", context.model),
|
||||
("request", request_body),
|
||||
*((("sse", answer_json),) if answer_json is not None else ()),
|
||||
)
|
||||
)
|
||||
session: Final = _v3_session_id(envelope, request_data, request_body)
|
||||
user: Final = _v3_user(envelope)
|
||||
return _frozen(
|
||||
(
|
||||
*phase,
|
||||
*((("session_id", session),) if session else ()),
|
||||
*(
|
||||
(("original", _frozen((("processed", _frozen((("Meta", _frozen((("user", user),))),))),))),)
|
||||
if user
|
||||
else ()
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _v3_conversation_prefixes(request_body: Mapping[str, object]) -> tuple[str, ...]:
|
||||
"""A fingerprint of the conversation after each of its messages, first to last.
|
||||
|
||||
The last one names the conversation as sent; the earlier ones let a request that
|
||||
carries a blocked exchange as its history be recognised, not only an exact resend.
|
||||
A `prompt` or a string `input` has one fingerprint.
|
||||
"""
|
||||
messages: Final = _v3_messages(request_body)
|
||||
if messages:
|
||||
digest: Final = hashlib.sha256()
|
||||
|
||||
def after(message: object) -> str:
|
||||
digest.update(json.dumps(message, sort_keys=True, default=str).encode("utf-8"))
|
||||
digest.update(b"\x1e")
|
||||
return digest.copy().hexdigest()
|
||||
|
||||
return tuple(after(message) for message in messages)
|
||||
plain: Final = request_body.get("input") if "input" in request_body else request_body.get("prompt")
|
||||
if plain is None:
|
||||
return ()
|
||||
return (hashlib.sha256(json.dumps(plain, sort_keys=True, default=str).encode("utf-8")).hexdigest(),)
|
||||
|
||||
|
||||
def _v3_session_id(
|
||||
envelope: StraikerWebhookRequest,
|
||||
request_data: Mapping[str, object],
|
||||
request_body: Mapping[str, object],
|
||||
) -> str | None:
|
||||
"""A stable id for the conversation, in Kong's order of precedence.
|
||||
|
||||
Claude Code names its session on the wire and that wins. Then the session LiteLLM
|
||||
resolved from its own metadata. Then, for a conversation that states none, a hash of
|
||||
the principal, the system prompt and the first message: a chat client replays the
|
||||
whole conversation on every turn, so that triple is constant for its lifetime and
|
||||
groups the turns. A fresh synthetic id per request would group nothing.
|
||||
|
||||
The principal is in the hash because Straiker skips turns it has already scored for a
|
||||
session. Two users who open with the same words are two conversations; hashed on the
|
||||
words alone they shared one session, and the second user's copy of an attack came
|
||||
back as a replay, unscored and allowed (measured 2026-09-20).
|
||||
"""
|
||||
supplied: Final = _request_header(request_data, V3_SESSION_HEADER)
|
||||
if supplied:
|
||||
return supplied
|
||||
if envelope.context.session_id:
|
||||
return envelope.context.session_id
|
||||
conversation: Final = f"{_v3_system_text(request_body) or ''}\0{_v3_first_message_text(request_body)}"
|
||||
if conversation == "\0":
|
||||
return None
|
||||
seed: Final = f"{_v3_user(envelope) or ''}\0{conversation}"
|
||||
return V3_DERIVED_SESSION_PREFIX + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
_V3_PREAMBLE_ROLES: Final = frozenset({"system", "developer"})
|
||||
|
||||
|
||||
def _v3_message_text(message: object) -> str:
|
||||
"""Every text block of a message, so a turn that opens with an image or a document still
|
||||
seeds on what the user wrote."""
|
||||
content: Final = message.get("content") if isinstance(message, Mapping) else None
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, (list, tuple)):
|
||||
return "\n".join(
|
||||
str(block["text"]) for block in content if isinstance(block, Mapping) and isinstance(block.get("text"), str)
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _v3_messages(request_body: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
|
||||
messages: Final = request_body.get("messages") or request_body.get("input")
|
||||
if isinstance(messages, (list, tuple)):
|
||||
return tuple(message for message in messages if isinstance(message, Mapping))
|
||||
return ()
|
||||
|
||||
|
||||
def _v3_system_text(request_body: Mapping[str, object]) -> str | None:
|
||||
"""The preamble, wherever the API puts it: Anthropic's `system`, the Responses API's
|
||||
`instructions`, or the leading system or developer message of an OpenAI chat body."""
|
||||
system: Final = request_body.get("system")
|
||||
if isinstance(system, str):
|
||||
return system
|
||||
if system is not None:
|
||||
return json.dumps(system, default=str)
|
||||
instructions: Final = request_body.get("instructions")
|
||||
if isinstance(instructions, str):
|
||||
return instructions
|
||||
preamble: Final = next((m for m in _v3_messages(request_body) if m.get("role") in _V3_PREAMBLE_ROLES), None)
|
||||
return _v3_message_text(preamble) if preamble is not None else None
|
||||
|
||||
|
||||
def _v3_first_message_text(request_body: Mapping[str, object]) -> str:
|
||||
"""What the user first said: the first `user` message, never the system prompt that an
|
||||
OpenAI chat body carries as `messages[0]`, else a Responses `input` string, else `prompt`."""
|
||||
first_user: Final = next((m for m in _v3_messages(request_body) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
return _v3_message_text(first_user)
|
||||
plain: Final = (
|
||||
request_body.get("input") if isinstance(request_body.get("input"), str) else request_body.get("prompt")
|
||||
)
|
||||
return plain if isinstance(plain, str) else ""
|
||||
|
||||
|
||||
def _v3_user(envelope: StraikerWebhookRequest) -> str | None:
|
||||
"""Who is asking: the key's own user first, then the end user the request named.
|
||||
|
||||
The key is the authenticated principal, the way a Kong consumer is, so a per-user key
|
||||
names the person even when the client packs something else into the body. Claude Code
|
||||
packs a hashed account-and-session token into `metadata.user_id`, which is what the end
|
||||
user resolves to when nothing better is set; it is a session, not a person, and only
|
||||
surfaces when the key names nobody. A master-key call resolves to LiteLLM's
|
||||
`default_user_id`; sent as an identity it would become one.
|
||||
"""
|
||||
identity: Final = envelope.identity
|
||||
for candidate in (identity.litellm_user_email, identity.litellm_user_id, identity.end_user_id):
|
||||
real = _real_identity(candidate)
|
||||
if real:
|
||||
return real
|
||||
return None
|
||||
|
||||
|
||||
def _v3_client_from_user_agent(request_data: Mapping[str, object]) -> tuple[str, str] | None:
|
||||
"""`(client, agent name)` for a User-Agent this gateway recognises, else None."""
|
||||
user_agent: Final = (_request_header(request_data, "user-agent") or "").lower()
|
||||
return next(
|
||||
(
|
||||
(client, f"{display} ({V3_GATEWAY_NAME})")
|
||||
for prefix, client, display in _V3_CLIENT_BY_USER_AGENT
|
||||
if user_agent.startswith(prefix)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _v3_headers(
|
||||
request_data: Mapping[str, object],
|
||||
agent_ref: str | None = None,
|
||||
client: str | None = None,
|
||||
format_hint: str | None = None,
|
||||
) -> Mapping[str, str]:
|
||||
"""Per-call routing hints, the unified Kong plugin's set. All optional.
|
||||
|
||||
`x-s6r-agent` names ONE application when a gateway fronts several: the route's
|
||||
`agent_ref`, else the caller's own header, else the agent this gateway names from the
|
||||
User-Agent. The operator's value comes first because the header is caller-supplied, and
|
||||
honouring it over a pinned route would let any key file its traffic under another
|
||||
application's agent and controls. `x-s6r-client` is the route's `client` config, else
|
||||
the client the User-Agent names. `x-s6r-format` comes from config alone. Claude Code's own session header is
|
||||
forwarded when the client sent it, which is how a coding session groups the way the
|
||||
native hook would.
|
||||
"""
|
||||
session: Final = _request_header(request_data, V3_SESSION_HEADER)
|
||||
recognised: Final = _v3_client_from_user_agent(request_data)
|
||||
agent: Final = (
|
||||
agent_ref or _request_header(request_data, V3_AGENT_HEADER) or (recognised[1] if recognised else None)
|
||||
)
|
||||
named_client: Final = client or (recognised[0] if recognised else None)
|
||||
candidates: Final = (
|
||||
(V3_SESSION_HEADER, session),
|
||||
(V3_AGENT_HEADER, agent),
|
||||
(V3_CLIENT_HEADER, named_client),
|
||||
(V3_FORMAT_HEADER, format_hint),
|
||||
)
|
||||
return MappingProxyType({name: value for name, value in candidates if value})
|
||||
|
||||
|
||||
def _v3_decision(body: Mapping[str, object]) -> tuple[str | None, Mapping[str, object]]:
|
||||
"""``(decision, verdict)``: the enforceable decision and the object carrying it.
|
||||
|
||||
Straiker answers in two envelopes. A relayed body gets the hook contract,
|
||||
`hookSpecificOutput.permissionDecision`, with the flat fields nested under `straiker`;
|
||||
a flat call answers `action` at the top level. Reading only one of them would silently
|
||||
make block mode a no-op on the other.
|
||||
"""
|
||||
nested: Final = body.get("straiker")
|
||||
verdict: Final = nested if isinstance(nested, Mapping) else body
|
||||
hook: Final = body.get("hookSpecificOutput")
|
||||
decision: Final = hook.get("permissionDecision") if isinstance(hook, Mapping) else None
|
||||
if isinstance(decision, str) and decision:
|
||||
return decision.lower(), verdict
|
||||
action: Final = verdict.get("action")
|
||||
return (action.lower() if isinstance(action, str) and action else None), verdict
|
||||
|
||||
|
||||
def _v3_response(body: Mapping[str, object]) -> StraikerWebhookResponse:
|
||||
"""Map a v3 verdict onto the action the guardrail already acts on.
|
||||
|
||||
A detect-mode control fires into `controls` without changing the decision, so it
|
||||
correctly reads NONE. `blocked_by` is the block-mode subset and is honoured even if a
|
||||
build answers it without flipping the decision.
|
||||
"""
|
||||
decision, verdict = _v3_decision(body)
|
||||
raw_blocked_by: Final = verdict.get("blocked_by")
|
||||
blocked_by: Final = tuple(sorted(str(c) for c in raw_blocked_by)) if isinstance(raw_blocked_by, list) else ()
|
||||
blocked: Final = decision in V3_BLOCK_DECISIONS or bool(blocked_by)
|
||||
stated: Final = (verdict.get("block_message"), verdict.get("deny_reason"), body.get("stopReason"))
|
||||
reason: Final = (
|
||||
next(
|
||||
(text.strip() for text in stated if isinstance(text, str) and text.strip()),
|
||||
f"Straiker blocked this turn: {', '.join(blocked_by) or 'policy'}",
|
||||
)
|
||||
if blocked
|
||||
else None
|
||||
)
|
||||
return StraikerWebhookResponse(
|
||||
action="BLOCKED" if blocked else "NONE",
|
||||
blocked_reason=reason,
|
||||
blocked_by=blocked_by,
|
||||
turnId=_as_optional_str(verdict.get("turn_id")) or _as_optional_str(body.get("turn_id")),
|
||||
)
|
||||
|
||||
|
||||
class StraikerGuardrail(CustomGuardrail):
|
||||
@staticmethod
|
||||
def get_config_model() -> type[GuardrailConfigModel]:
|
||||
|
|
@ -284,6 +864,10 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
self,
|
||||
api_key: str,
|
||||
api_base: str = DEFAULT_API_BASE,
|
||||
api_version: Literal["v1", "v3"] | None = None,
|
||||
agent_ref: str | None = None,
|
||||
client: str | None = None,
|
||||
format_hint: Literal["anthropic.messages", "openai.chat"] | None = None,
|
||||
source: str = "LiteLLM Gateway",
|
||||
timeout: float = 5.0,
|
||||
max_retries: int = 2,
|
||||
|
|
@ -302,9 +886,28 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
raise ValueError("api_key must be non-empty")
|
||||
if unreachable_fallback not in ("fail_open", "fail_closed"):
|
||||
raise ValueError(f"unreachable_fallback must be 'fail_open' or 'fail_closed'; got {unreachable_fallback!r}")
|
||||
if api_version is None:
|
||||
# The key names the platform: a v3 integration key cannot call v1 and a v1
|
||||
# collection key cannot call v3, so an unset version follows the key.
|
||||
api_version = "v3" if api_key.startswith(V3_KEY_PREFIX) else "v1"
|
||||
if api_version not in ("v1", "v3"):
|
||||
raise ValueError(f"api_version must be 'v1' or 'v3'; got {api_version!r}")
|
||||
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.api_version = api_version
|
||||
self.agent_ref = _as_optional_str(agent_ref)
|
||||
self.client = _as_optional_str(client)
|
||||
if format_hint is not None and format_hint not in ("anthropic.messages", "openai.chat"):
|
||||
raise ValueError(f"format_hint must be 'anthropic.messages' or 'openai.chat'; got {format_hint!r}")
|
||||
self.format_hint = format_hint
|
||||
# Blocked conversations by session, so a resend or a conversation grown past a blocked
|
||||
# turn is blocked again here: Straiker de-duplicates turns it has already scored per
|
||||
# session and answers a replay `allow`, whatever the original verdict was (measured
|
||||
# 2026-09-20). Per process; a replica that did not see the block asks Straiker.
|
||||
self._v3_blocked_turns = InMemoryCache(
|
||||
max_size_in_memory=V3_BLOCKED_TURN_MEMORY, default_ttl=V3_BLOCKED_TURN_TTL_SECONDS
|
||||
)
|
||||
self.source = source
|
||||
self.timeout = float(timeout)
|
||||
self.max_retries = max(0, int(max_retries))
|
||||
|
|
@ -330,17 +933,18 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
self.configured_modes = _configured_modes(self.event_hook)
|
||||
|
||||
def _webhook_url(self) -> str:
|
||||
return f"{self.api_base}{WEBHOOK_PATH}"
|
||||
return f"{self.api_base}{V3_DETECT_PATH if self.api_version == 'v3' else WEBHOOK_PATH}"
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
reserved: Final = {"authorization", "content-type", "x-straiker-webhook-format"}
|
||||
extra: Final = {k: v for k, v in self.custom_headers.items() if k.lower() not in reserved}
|
||||
return {
|
||||
headers: Final = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Straiker-Webhook-Format": "litellm",
|
||||
**extra,
|
||||
}
|
||||
if self.api_version != "v3":
|
||||
headers["X-Straiker-Webhook-Format"] = "litellm"
|
||||
return {**headers, **extra}
|
||||
|
||||
def _build_application(self, request_data: dict) -> StraikerWebhookApplication:
|
||||
meta: Final = _merged_metadata(request_data)
|
||||
|
|
@ -417,9 +1021,11 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
metadata=_build_webhook_metadata(request_data, self.default_metadata),
|
||||
)
|
||||
|
||||
async def _post_webhook(self, payload: dict) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
|
||||
async def _post_webhook(
|
||||
self, payload: Mapping[str, object], headers: Mapping[str, str] | None = None
|
||||
) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
|
||||
try:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
body: Final = json.dumps(payload, default=_json_default).encode("utf-8")
|
||||
except (TypeError, ValueError, OverflowError) as error:
|
||||
return None, _WebhookFailure(f"request serialization failed: {error}", is_unreachable=False)
|
||||
body_bytes: Final = len(body)
|
||||
|
|
@ -430,7 +1036,7 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
)
|
||||
|
||||
url: Final = self._webhook_url()
|
||||
headers: Final = self._headers()
|
||||
merged_headers: Final = {**self._headers(), **(headers or {})}
|
||||
attempts: Final = self.max_retries + 1
|
||||
last_failure: _WebhookFailure | None = None
|
||||
|
||||
|
|
@ -443,48 +1049,58 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
"bytes": body_bytes,
|
||||
"payload": payload,
|
||||
},
|
||||
default=str,
|
||||
default=_json_default,
|
||||
)
|
||||
)
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
resp = await self.async_handler.post(url, content=body, headers=headers, timeout=self.timeout)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
body = resp.json()
|
||||
parsed = StraikerWebhookResponse.model_validate(body)
|
||||
except (ValidationError, json.JSONDecodeError) as ve:
|
||||
return None, _WebhookFailure(f"invalid response schema: {ve}", is_unreachable=False)
|
||||
if self.verbose:
|
||||
verbose_proxy_logger.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "straiker.webhook_response",
|
||||
"status_code": resp.status_code,
|
||||
"body": body,
|
||||
},
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
return parsed, None
|
||||
last_failure = _WebhookFailure(
|
||||
f"HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
is_unreachable=resp.status_code in UNREACHABLE_STATUS,
|
||||
)
|
||||
if resp.status_code not in RETRY_STATUS:
|
||||
return None, last_failure
|
||||
except (httpx.RequestError, asyncio.TimeoutError, Timeout) as e:
|
||||
last_failure = _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=True)
|
||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
||||
return None, _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=False)
|
||||
|
||||
parsed, last_failure = await self._attempt(url, body, merged_headers)
|
||||
if last_failure is None or not last_failure.retryable:
|
||||
return parsed, last_failure
|
||||
if attempt < attempts - 1:
|
||||
backoff = min(self.initial_backoff * (2**attempt), self.max_backoff)
|
||||
await asyncio.sleep(random.uniform(0, backoff))
|
||||
|
||||
return None, last_failure or _WebhookFailure("unknown error", is_unreachable=True)
|
||||
|
||||
async def _attempt(
|
||||
self, url: str, body: bytes, headers: dict[str, str]
|
||||
) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
|
||||
try:
|
||||
resp: Final = await self.async_handler.post(url, content=body, headers=headers, timeout=self.timeout)
|
||||
except httpx.HTTPStatusError as status_error:
|
||||
return None, _status_failure(status_error.response.status_code, _error_response_text(status_error.response))
|
||||
except (httpx.RequestError, asyncio.TimeoutError, Timeout) as e:
|
||||
return None, _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=True, retryable=True)
|
||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
||||
return None, _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=False)
|
||||
if resp is None:
|
||||
return None, _WebhookFailure("no response", is_unreachable=True, retryable=True)
|
||||
if resp.status_code == 200:
|
||||
return self._parse_verdict(resp)
|
||||
return None, _status_failure(resp.status_code, resp.text)
|
||||
|
||||
def _parse_verdict(self, resp: httpx.Response) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
|
||||
try:
|
||||
body: Final = resp.json()
|
||||
if not isinstance(body, Mapping):
|
||||
return None, _WebhookFailure(
|
||||
f"invalid response schema: expected an object, got {type(body).__name__}", is_unreachable=False
|
||||
)
|
||||
parsed: Final = (
|
||||
_v3_response(body) if self.api_version == "v3" else StraikerWebhookResponse.model_validate(body)
|
||||
)
|
||||
except (ValidationError, json.JSONDecodeError) as ve:
|
||||
return None, _WebhookFailure(f"invalid response schema: {ve}", is_unreachable=False)
|
||||
if self.verbose:
|
||||
verbose_proxy_logger.info(
|
||||
json.dumps(
|
||||
{"event": "straiker.webhook_response", "status_code": resp.status_code, "body": body},
|
||||
default=_json_default,
|
||||
)
|
||||
)
|
||||
return parsed, None
|
||||
|
||||
def _record(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -519,7 +1135,7 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
"error": error,
|
||||
"fail_open": fail_open,
|
||||
},
|
||||
default=str,
|
||||
default=_json_default,
|
||||
)
|
||||
)
|
||||
if fail_open:
|
||||
|
|
@ -564,6 +1180,76 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
return_inputs["texts"] = parsed.texts
|
||||
return return_inputs
|
||||
|
||||
async def _apply_v3(
|
||||
self,
|
||||
*,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: LiteLLMLoggingObj | None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
"""One phase of a turn against /api/v3/detect: relay, read the decision, enforce."""
|
||||
try:
|
||||
envelope: Final = self._build_envelope(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type=input_type,
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
payload: Final = _v3_payload(envelope, inputs, request_data, input_type)
|
||||
headers: Final = _v3_headers(request_data, self.agent_ref, self.client, self.format_hint)
|
||||
request_body: Final = _v3_request_body(request_data)
|
||||
# The memory is scoped by the session, else by the principal; a request that has
|
||||
# neither is never remembered, so no two callers can share a block.
|
||||
scope: Final = _v3_session_id(envelope, request_data, request_body) or _v3_user(envelope) or ""
|
||||
prefixes: Final = _v3_conversation_prefixes(request_body) if scope else ()
|
||||
except (ValidationError, TypeError, ValueError) as error:
|
||||
return self._fail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type=input_type,
|
||||
error=str(error),
|
||||
is_unreachable=False,
|
||||
)
|
||||
|
||||
replayed: Final = self._v3_replayed_block(scope, prefixes) if input_type == "request" else None
|
||||
if replayed is not None:
|
||||
self._block(request_data=request_data, input_type=input_type, message=replayed, blocked_content=True)
|
||||
|
||||
parsed, failure = await self._post_webhook(payload, headers)
|
||||
if failure is not None or parsed is None:
|
||||
return self._fail(
|
||||
inputs=inputs,
|
||||
request_data=request_data,
|
||||
input_type=input_type,
|
||||
error=failure.message if failure is not None else "empty response from Straiker",
|
||||
is_unreachable=failure.is_unreachable if failure is not None else False,
|
||||
)
|
||||
self._record(request_data=request_data, logging_obj=logging_obj, parsed=parsed)
|
||||
if parsed.action == "BLOCKED":
|
||||
message: Final = parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE
|
||||
# Only a block that names a control is remembered. The same words are the same
|
||||
# attack tomorrow, but a block that comes from state -- an engaged kill switch,
|
||||
# a governance action -- is lifted by an administrator, and a remembered copy
|
||||
# would keep refusing a conversation the platform now allows.
|
||||
if prefixes and parsed.blocked_by:
|
||||
self._v3_blocked_turns.set_cache(f"{scope}\0{prefixes[-1]}", message)
|
||||
self._block(request_data=request_data, input_type=input_type, message=message, blocked_content=True)
|
||||
return inputs
|
||||
|
||||
def _v3_replayed_block(self, scope: str, prefixes: tuple[str, ...]) -> str | None:
|
||||
"""The block message a conversation already earned, when this request repeats or
|
||||
extends a conversation this process blocked in the same scope (session or principal)."""
|
||||
for prefix in prefixes:
|
||||
message: str | None = self._v3_blocked_turns.get_cache(f"{scope}\0{prefix}")
|
||||
if message is not None:
|
||||
if self.verbose:
|
||||
verbose_proxy_logger.info(
|
||||
json.dumps({"event": "straiker.replay_blocked", "scope": scope, "prefix": prefix})
|
||||
)
|
||||
return message
|
||||
return None
|
||||
|
||||
@log_guardrail_information
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
|
|
@ -572,6 +1258,10 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
input_type: Literal["request", "response"],
|
||||
logging_obj: LiteLLMLoggingObj | None = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
if self.api_version == "v3":
|
||||
return await self._apply_v3(
|
||||
inputs=inputs, request_data=request_data, input_type=input_type, logging_obj=logging_obj
|
||||
)
|
||||
try:
|
||||
envelope: Final = self._build_envelope(
|
||||
inputs=inputs,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ class StraikerWebhookResponse(BaseModel):
|
|||
|
||||
action: StraikerWebhookAction = "NONE"
|
||||
blocked_reason: str | None = None
|
||||
#: The controls that blocked this turn, when the platform names them. Empty for a block
|
||||
#: that comes from state rather than content, such as an engaged kill switch.
|
||||
blocked_by: tuple[str, ...] = ()
|
||||
texts: list[str] | None = None
|
||||
schema_version: str | None = None
|
||||
turn_id: str | None = Field(default=None, alias="turnId")
|
||||
|
|
@ -125,6 +128,36 @@ class StraikerGuardrailConfigModelOptionalParams(BaseModel):
|
|||
gt=0,
|
||||
description="Maximum serialized webhook payload size sent to Straiker.",
|
||||
)
|
||||
api_version: Literal["v1", "v3"] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Straiker detect API the gateway calls. 'v1' posts the structured webhook envelope "
|
||||
"to /api/v1/detect/webhook (legacy Defend, UUID collection key). 'v3' relays the "
|
||||
"provider request and response to /api/v3/detect, the v3 platform's only detect "
|
||||
"route, which accepts only an sk_agt_ integration key. Unset: chosen from the key "
|
||||
"prefix, so a v3 key needs no extra configuration."
|
||||
),
|
||||
)
|
||||
agent_ref: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"v3 only. Names the Straiker agent this route's traffic belongs to when one gateway "
|
||||
"fronts several applications, sent as x-s6r-agent. A client-supplied x-s6r-agent header "
|
||||
"wins. Names ONE agent, never a kind of agent: Straiker keys per-agent state on it, so "
|
||||
"sharing a value across applications merges them into one agent."
|
||||
),
|
||||
)
|
||||
client: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"v3 only. Optional x-s6r-client routing hint. Leave unset on a shared gateway; set it on a "
|
||||
"route that serves a single application."
|
||||
),
|
||||
)
|
||||
format_hint: Literal["anthropic.messages", "openai.chat"] | None = Field(
|
||||
default=None,
|
||||
description="v3 only. Optional x-s6r-format hint. Only breaks the messages-array tie between formats.",
|
||||
)
|
||||
custom_headers: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description="Additional HTTP headers sent to Straiker, excluding Authorization and the webhook-format header.",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue