Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/guardrails/guardrail_hooks/straiker/straiker.py: 17%
593 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1from __future__ import annotations
3import asyncio
4import hashlib
5import json
6import random
7from collections.abc import Iterable, Mapping
8from dataclasses import dataclass
9from types import MappingProxyType
10from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn
11from urllib.parse import urlsplit
13import httpx
14from pydantic import BaseModel, TypeAdapter, ValidationError
16from litellm._logging import verbose_proxy_logger
17from litellm._version import version as litellm_version
18from litellm.caching.in_memory_cache import InMemoryCache
19from litellm.exceptions import (
20 BadRequestError,
21 GuardrailRaisedException,
22 ModifyResponseException,
23 Timeout,
24)
25from litellm.integrations.custom_guardrail import (
26 CustomGuardrail,
27 get_session_id_from_request_data,
28 log_guardrail_information,
29)
30from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
31from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages
32from litellm.llms.custom_httpx.http_handler import (
33 get_async_httpx_client,
34 httpxSpecialProvider,
35)
36from litellm.proxy._types import SpecialProxyStrings
37from litellm.types.guardrails import GuardrailEventHooks, Mode
38from litellm.types.proxy.guardrails.guardrail_hooks.straiker import (
39 STRAIKER_WEBHOOK_SCHEMA_VERSION,
40 StraikerGuardrailConfigModel,
41 StraikerWebhookApplication,
42 StraikerWebhookContent,
43 StraikerWebhookContext,
44 StraikerWebhookEvent,
45 StraikerWebhookIdentity,
46 StraikerWebhookRequest,
47 StraikerWebhookResponse,
48 StraikerWebhookStream,
49 StraikerWebhookUsage,
50)
51from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs, ModelResponse, TextCompletionResponse
53if TYPE_CHECKING: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
55 from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
57GUARDRAIL_NAME: Final = "straiker"
58DEFAULT_BLOCK_MESSAGE: Final = "Content violates policy"
59DEFAULT_API_BASE: Final = "https://api.prod.straiker.ai"
60DEFAULT_MAX_PAYLOAD_BYTES: Final = 524288
61WEBHOOK_PATH: Final = "/api/v1/detect/webhook"
62V3_DETECT_PATH: Final = "/api/v3/detect"
63V3_KEY_PREFIX: Final = "sk_agt_"
64V3_SESSION_HEADER: Final = "x-claude-code-session-id"
65V3_CLIENT_HEADER: Final = "x-s6r-client"
66V3_FORMAT_HEADER: Final = "x-s6r-format"
67# (User-Agent prefix, Straiker client value, display name). Straiker recognises a coding agent
68# from the system prompt of its main turns only; Claude Code's title and topic sidecars carry
69# other prompts and would split the session across two agents. The User-Agent is on every call.
70_V3_CLIENT_BY_USER_AGENT: Final = (("claude-cli/", "claude", "Claude"),)
71V3_GATEWAY_NAME: Final = "LiteLLM"
72V3_DERIVED_SESSION_PREFIX: Final = "litellm-"
73V3_AGENT_HEADER: Final = "x-s6r-agent"
74V3_RESPONSE_PHASE: Final = "response-sync"
75V3_BLOCK_DECISIONS: Final = frozenset({"block", "deny"})
76V3_BLOCKED_TURN_MEMORY: Final = 10_000
77V3_BLOCKED_TURN_TTL_SECONDS: Final = 24 * 60 * 60
78# An allowlist: the hook's request dict merges the client body with proxy state (`deployment`
79# carries the resolved credential), so only fields named here are relayed.
80_V3_PROVIDER_BODY_KEYS: Final = frozenset(
81 {
82 "model",
83 "messages",
84 "tools",
85 "tool_choice",
86 "functions",
87 "function_call",
88 "temperature",
89 "top_p",
90 "n",
91 "stream",
92 "stream_options",
93 "stop",
94 "max_tokens",
95 "max_completion_tokens",
96 "presence_penalty",
97 "frequency_penalty",
98 "logit_bias",
99 "user",
100 "response_format",
101 "seed",
102 "logprobs",
103 "top_logprobs",
104 "parallel_tool_calls",
105 "reasoning_effort",
106 "modalities",
107 "audio",
108 "prediction",
109 "store",
110 "service_tier",
111 "web_search_options",
112 "prompt",
113 "suffix",
114 "echo",
115 "best_of",
116 "system",
117 "stop_sequences",
118 "top_k",
119 "thinking",
120 "container",
121 "mcp_servers",
122 "context_management",
123 "output_format",
124 "input",
125 "instructions",
126 "previous_response_id",
127 "truncation",
128 "text",
129 "include",
130 "reasoning",
131 "max_output_tokens",
132 "background",
133 "conversation",
134 "session_id",
135 }
136)
137# The scrub of these is one level deep on purpose: a function schema that defines a `token` or
138# `headers` property lives under `function.parameters` and must be relayed as sent.
139_V3_CREDENTIAL_FIELDS: Final = frozenset({"authorization_token", "authorization", "headers"})
140_V3_REDACTED_VALUE: Final = "[redacted]"
141_V3_REDACTED_KEYS: Final = frozenset({"tools", "mcp_servers"})
142_V3_IDENTITY_METADATA_KEYS: Final = (
143 "user_api_key_end_user_id",
144 "user_api_key_user_email",
145 "user_api_key_user_id",
146 "user_api_key_alias",
147 "user_api_key_team_id",
148)
149RETRY_STATUS: Final = frozenset({408, 429, 500, 502, 503, 504})
150UNREACHABLE_STATUS: Final = frozenset({502, 503, 504})
151_APPLICATION_METADATA_KEYS: Final = frozenset({"agent_id", "app_name"})
152_OPAQUE_METADATA_SCALAR_TYPES: Final = (str, int, float, bool)
153_JSON_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
156@dataclass(frozen=True, slots=True)
157class _WebhookFailure:
158 message: str
159 is_unreachable: bool
160 retryable: bool = False
163def _status_failure(status: int, text: str) -> _WebhookFailure:
164 return _WebhookFailure(
165 f"HTTP {status}: {text[:200]}",
166 is_unreachable=status in UNREACHABLE_STATUS,
167 retryable=status in RETRY_STATUS,
168 )
171def _error_response_text(response: httpx.Response) -> str:
172 try:
173 return response.text
174 except Exception: # noqa: BLE001 # a masked response may carry no body
175 return ""
178def _as_dict(value: object) -> dict:
179 return value if isinstance(value, dict) else {}
182def _merged_metadata(request_data: Mapping[str, object]) -> dict:
183 return {
184 **_as_dict(request_data.get("metadata")),
185 **_as_dict(request_data.get("litellm_metadata")),
186 }
189def _as_optional_str(value: object) -> str | None:
190 return value if isinstance(value, str) and value else None
193def _build_webhook_metadata(request_data: dict, default_metadata: dict[str, str]) -> dict[str, object] | None:
194 out: Final[dict[str, object]] = {}
195 for key, value in _as_dict(request_data.get("metadata")).items():
196 if key in _APPLICATION_METADATA_KEYS or key.startswith("user_api"):
197 continue
198 if key == "session_id":
199 continue
200 if isinstance(value, _OPAQUE_METADATA_SCALAR_TYPES):
201 out[key] = value
202 out.update(default_metadata)
203 return out or None
206def _extract_identity(request_data: dict) -> StraikerWebhookIdentity:
207 meta: Final = _merged_metadata(request_data)
208 return StraikerWebhookIdentity(
209 litellm_key=_as_optional_str(meta.get("user_api_key_alias"))
210 or _as_optional_str(meta.get("user_api_key_hash"))
211 or _as_optional_str(meta.get("user_api_key_token")),
212 litellm_team=_as_optional_str(meta.get("user_api_key_team_alias"))
213 or _as_optional_str(meta.get("user_api_key_team_id")),
214 litellm_user_id=_as_optional_str(meta.get("user_api_key_user_id")),
215 litellm_user_email=_as_optional_str(meta.get("user_api_key_user_email")),
216 litellm_org_id=_as_optional_str(meta.get("user_api_key_org_id")),
217 end_user_id=_as_optional_str(meta.get("user_api_key_end_user_id")),
218 )
221def _resolve_provider(request_data: dict, model: str | None) -> str | None:
222 litellm_params: Final = _as_dict(request_data.get("litellm_params"))
223 custom_llm_provider: Final = request_data.get("custom_llm_provider") or litellm_params.get("custom_llm_provider")
224 if custom_llm_provider:
225 return custom_llm_provider
226 if not model:
227 return None
228 try:
229 _, provider, _, _ = get_llm_provider(
230 model=model,
231 api_base=request_data.get("api_base") or litellm_params.get("api_base"),
232 api_key=request_data.get("api_key") or litellm_params.get("api_key"),
233 )
234 except BadRequestError:
235 return None
236 return provider or None
239def _resolve_destination(request_data: dict) -> str | None:
240 litellm_params: Final = _as_dict(request_data.get("litellm_params"))
241 api_base: Final = request_data.get("api_base") or litellm_params.get("api_base")
242 if not isinstance(api_base, str):
243 return None
244 try:
245 return urlsplit(api_base).hostname
246 except ValueError:
247 return None
250def _route_has_translation(request_data: dict) -> bool:
251 from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
252 from litellm.llms import load_guardrail_translation_mappings
254 route: Final = _as_dict(request_data.get("litellm_metadata")).get("user_api_key_request_route")
255 if not isinstance(route, str) or not route:
256 return False
257 mappings: Final = load_guardrail_translation_mappings()
258 return any(call_type in mappings for call_type in get_call_types_for_route(route) or ())
261def _request_structured_messages(request_data: dict) -> list[dict[str, Any]] | None:
262 messages: Final = request_data.get("messages")
263 if messages:
264 return messages if isinstance(messages, list) else None
265 if not _route_has_translation(request_data):
266 return None
267 return resolve_structured_messages(messages=None, request_kwargs=request_data)
270def _hook_name(value: object) -> str:
271 return value.value if isinstance(value, GuardrailEventHooks) else str(value)
274def _configured_modes(event_hook: object) -> list[str] | None:
275 if isinstance(event_hook, list):
276 names = [_hook_name(v) for v in event_hook]
277 elif isinstance(event_hook, (str, GuardrailEventHooks)):
278 names = [_hook_name(event_hook)]
279 elif isinstance(event_hook, Mode):
280 default: Final = event_hook.default if isinstance(event_hook.default, list) else [event_hook.default]
281 tags: Final = [v for value in event_hook.tags.values() for v in (value if isinstance(value, list) else [value])]
282 names = [_hook_name(v) for v in (*default, *tags) if v is not None]
283 else:
284 return None
285 return list(dict.fromkeys(names)) or None
288def _resolve_call_surface(logging_obj: LiteLLMLoggingObj | None, request_data: dict) -> str:
289 call_type: Final = (
290 (getattr(logging_obj, "call_type", None) if logging_obj is not None else None)
291 or request_data.get("call_type")
292 or request_data.get("litellm_call_type")
293 )
294 return call_type if isinstance(call_type, str) and call_type else "unknown"
297def _jsonable_dict(value: object) -> dict[str, object] | None:
298 if isinstance(value, BaseModel):
299 return _JSON_DICT_ADAPTER.validate_python(value.model_dump(mode="json", exclude_none=True))
300 if isinstance(value, dict):
301 return _JSON_DICT_ADAPTER.validate_python(value)
302 return None
305def _opaque_dict_list(value: object) -> list[dict[str, object]] | None:
306 if not isinstance(value, list):
307 return None
308 items: Final = tuple(plain for item in value if (plain := _jsonable_dict(item)) is not None)
309 return list(items) if items else None
312def _choice_terminal_reason(choice: object) -> str | None:
313 if isinstance(choice, dict):
314 return _as_optional_str(choice.get("finish_reason")) or _as_optional_str(choice.get("stop_reason"))
315 return _as_optional_str(getattr(choice, "finish_reason", None)) or _as_optional_str(
316 getattr(choice, "stop_reason", None)
317 )
320def _response_finish_reason(response: Any) -> str | None:
321 if response is None:
322 return None
323 if isinstance(response, dict):
324 top = _as_optional_str(response.get("finish_reason")) or _as_optional_str(response.get("stop_reason"))
325 if top:
326 return top
327 choices = response.get("choices")
328 if not isinstance(choices, list):
329 return None
330 for choice in choices:
331 reason = _choice_terminal_reason(choice)
332 if reason:
333 return reason
334 return None
336 top = _as_optional_str(getattr(response, "finish_reason", None)) or _as_optional_str(
337 getattr(response, "stop_reason", None)
338 )
339 if top:
340 return top
341 choices = getattr(response, "choices", None)
342 if not isinstance(choices, list):
343 return None
344 for choice in choices:
345 reason = _choice_terminal_reason(choice)
346 if reason:
347 return reason
348 return None
351def _as_optional_int(value: object) -> int | None:
352 return value if isinstance(value, int) and not isinstance(value, bool) else None
355def _usage_token_count(usage: object, openai_key: str, anthropic_key: str) -> int | None:
356 get: Final = usage.get if isinstance(usage, dict) else lambda key: getattr(usage, key, None)
357 openai_count: Final = _as_optional_int(get(openai_key))
358 return openai_count if openai_count is not None else _as_optional_int(get(anthropic_key))
361def _build_usage(response: object) -> StraikerWebhookUsage | None:
362 usage: Final = response.get("usage") if isinstance(response, dict) else getattr(response, "usage", None)
363 if usage is None:
364 return None
365 input_tokens: Final = _usage_token_count(usage, "prompt_tokens", "input_tokens")
366 output_tokens: Final = _usage_token_count(usage, "completion_tokens", "output_tokens")
367 if input_tokens is None and output_tokens is None:
368 return None
369 return StraikerWebhookUsage(input_tokens=input_tokens, output_tokens=output_tokens)
372def _is_streamed_request(request_data: dict) -> bool:
373 if request_data.get("stream") is True:
374 return True
375 body: Final = _as_dict(_as_dict(request_data.get("proxy_server_request")).get("body"))
376 return body.get("stream") is True
379# What the proxy stamps on a master-key call in place of a person. Sent onward, either
380# would be recorded as an identity and every master-key turn filed under it.
381_PLACEHOLDER_IDENTITIES: Final = frozenset({SpecialProxyStrings.default_user_id.value, "litellm_proxy_master_key"})
384def _real_identity(value: object) -> str | None:
385 """LiteLLM's proxy-admin placeholders are not a person."""
386 identity: Final = _as_optional_str(value)
387 return None if identity in _PLACEHOLDER_IDENTITIES else identity
390def _request_header(request_data: Mapping[str, object], name: str | None) -> str | None:
391 """A header from the inbound request, when LiteLLM kept it on the request data."""
392 if not name:
393 return None
394 proxy_request: Final = request_data.get("proxy_server_request")
395 headers: Final = proxy_request.get("headers") if isinstance(proxy_request, Mapping) else None
396 if not isinstance(headers, Mapping):
397 return None
398 wanted: Final = name.lower()
399 for key, value in headers.items():
400 if str(key).lower() == wanted and isinstance(value, str) and value.strip():
401 return value.strip()
402 return None
405def _frozen(pairs: Iterable[tuple[str, object]]) -> Mapping[str, object]:
406 return MappingProxyType(dict(pairs))
409def _json_default(value: object) -> object:
410 if isinstance(value, Mapping):
411 return dict(value) # mutable-ok: the JSON encoder needs a dict view of a frozen mapping
412 return str(value)
415def _v3_identity_metadata(request_data: Mapping[str, object]) -> Mapping[str, str]:
416 """The proxy-resolved identity fields, and only those, for the relayed body."""
417 merged: Final = _merged_metadata(request_data)
418 return MappingProxyType(
419 {key: value for key in _V3_IDENTITY_METADATA_KEYS if (value := _real_identity(merged.get(key)))}
420 )
423def _v3_request_body(request_data: Mapping[str, object]) -> Mapping[str, object]:
424 """The provider body LiteLLM received, stripped of everything the proxy added.
426 The hook sees the client's request merged with proxy bookkeeping: logging objects,
427 the resolved key, the inbound headers. Only the provider body is Straiker's to read,
428 and the client's Authorization header must not travel. Identity survives as the
429 metadata subset the Straiker LiteLLM adapter reads.
430 """
431 identity: Final = _v3_identity_metadata(request_data)
432 turns: Final = (
433 _v3_prompt_as_messages(request_data.get("prompt"))
434 if _v3_text_completion_route(request_data) and "messages" not in request_data
435 else None
436 )
437 provider: Final = (
438 (key, _v3_without_credentials(value) if key in _V3_REDACTED_KEYS else value)
439 for key, value in request_data.items()
440 if key in _V3_PROVIDER_BODY_KEYS and not (turns is not None and key == "prompt")
441 )
442 prompt_turns: Final = (("messages", turns),) if turns is not None else ()
443 return _frozen((*provider, *prompt_turns, *((("metadata", identity),) if identity else ())))
446def _v3_without_credentials(entries: object) -> object:
447 if not isinstance(entries, (list, tuple)):
448 return entries
449 return tuple(
450 _frozen(
451 (str(key), _V3_REDACTED_VALUE if str(key).lower() in _V3_CREDENTIAL_FIELDS else item)
452 for key, item in entry.items()
453 )
454 if isinstance(entry, Mapping)
455 else entry
456 for entry in entries
457 )
460def _v3_route_is(request_data: Mapping[str, object], call_type: CallTypes) -> bool:
461 from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
463 route: Final = _merged_metadata(request_data).get("user_api_key_request_route")
464 if not isinstance(route, str) or not route:
465 return False
466 return call_type in (get_call_types_for_route(route) or ())
469def _v3_anthropic_messages_route(request_data: Mapping[str, object]) -> bool:
470 return _v3_route_is(request_data, CallTypes.anthropic_messages)
473def _v3_text_completion_route(request_data: Mapping[str, object]) -> bool:
474 return _v3_route_is(request_data, CallTypes.text_completion)
477def _v3_is_token_list(value: object) -> bool:
478 return (
479 isinstance(value, (list, tuple))
480 and bool(value)
481 and all(isinstance(token, int) and not isinstance(token, bool) for token in value)
482 )
485def _v3_decode_tokens(tokens: Iterable[object]) -> str | None:
486 ids: Final = [token for token in tokens if isinstance(token, int)] # mutable-ok: tiktoken decodes a list
487 try:
488 import tiktoken
490 return tiktoken.encoding_for_model("text-davinci-003").decode(ids)
491 except Exception: # noqa: BLE001 # no tokenizer available: the raw prompt is relayed instead
492 return None
495def _v3_prompt_texts(prompt: object) -> tuple[str, ...] | None:
496 """The text the model receives for a completions `prompt`, in the proxy's own terms.
498 LiteLLM accepts a string, a list of strings, a list of token ids, or a list of token-id
499 lists, and decodes token ids with the text-davinci-003 tokenizer before calling the model.
500 The same decoding here means Straiker screens what the model gets. None when the prompt
501 is a shape this cannot render, so the caller relays it untouched rather than screening
502 something else.
503 """
504 if isinstance(prompt, str):
505 return (prompt,)
506 if not isinstance(prompt, (list, tuple)) or not prompt:
507 return None
508 if all(isinstance(item, str) for item in prompt):
509 return tuple(str(item) for item in prompt)
510 if _v3_is_token_list(prompt):
511 decoded: Final = _v3_decode_tokens(prompt)
512 return (decoded,) if decoded is not None else None
513 if all(_v3_is_token_list(item) for item in prompt):
514 decoded_each: Final = tuple(_v3_decode_tokens(item) for item in prompt)
515 return None if any(text is None for text in decoded_each) else tuple(text or "" for text in decoded_each)
516 return None
519def _v3_prompt_as_messages(prompt: object) -> tuple[Mapping[str, object], ...] | None:
520 texts: Final = _v3_prompt_texts(prompt)
521 if texts is None:
522 return None
523 return tuple(_frozen((("role", "user"), ("content", text))) for text in texts)
526def _v3_answer(request_data: Mapping[str, object], model: str | None) -> Mapping[str, object] | None:
527 """The answer in the API shape the client spoke, which is what a relay forwards.
529 On a streamed Messages call the proxy rebuilds the answer as a chat completion before
530 the hook runs. Straiker's coding-agent reader parses a Messages answer, so a Claude Code
531 turn sent as a chat completion scores nothing; the proxy's own adapter turns it back.
532 """
533 response: Final = request_data.get("response")
534 if isinstance(response, TextCompletionResponse):
535 return _v3_text_completion_as_chat(response)
536 if not isinstance(response, ModelResponse) or not _v3_anthropic_messages_route(request_data):
537 return _jsonable_dict(response)
538 from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
539 LiteLLMAnthropicMessagesAdapter,
540 )
542 translated: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response=response)
543 re_keyed: Final = dict(translated, model=response.model or model) # mutable-ok: adapter TypedDict re-keyed
544 return _jsonable_dict(re_keyed)
547def _v3_text_completion_as_chat(response: TextCompletionResponse) -> Mapping[str, object]:
548 """A legacy completion answer in the chat shape the platform scores.
550 Straiker has no reader for a `text_completion` answer on a gateway: the request phase
551 of a /v1/completions call is scored, the response phase is refused. A completion is one
552 user turn and one assistant turn, so both phases are presented as that exchange.
553 """
554 choices: Final = tuple(
555 _frozen(
556 (
557 ("index", index),
558 ("finish_reason", getattr(choice, "finish_reason", None)),
559 ("message", _frozen((("role", "assistant"), ("content", getattr(choice, "text", "") or "")))),
560 )
561 )
562 for index, choice in enumerate(response.choices)
563 )
564 usage: Final = _jsonable_dict(getattr(response, "usage", None))
565 return _frozen(
566 (
567 ("id", response.id),
568 ("object", "chat.completion"),
569 ("created", response.created),
570 ("model", response.model),
571 ("choices", choices),
572 *((("usage", usage),) if usage else ()),
573 )
574 )
577def _v3_answer_json(
578 inputs: GenericGuardrailAPIInputs, request_data: Mapping[str, object], model: str | None
579) -> str | None:
580 """The model's answer as the raw response body Straiker parses on the response phase.
582 The real response object carries tool calls, which a coding-agent turn is scored on,
583 so it is preferred. A streamed answer reaches the hook already assembled into texts,
584 and those become a minimal chat completion so the answer is still scored.
585 """
586 response: Final = _v3_answer(request_data, model)
587 if response:
588 return json.dumps(response, default=_json_default)
589 texts: Final = tuple(t for t in (inputs.get("texts") or []) if t)
590 if not texts:
591 return None
592 message: Final = _frozen((("role", "assistant"), ("content", "\n".join(texts))))
593 choice: Final = _frozen((("index", 0), ("finish_reason", "stop"), ("message", message)))
594 return json.dumps(_frozen((("object", "chat.completion"), ("choices", (choice,)))), default=_json_default)
597def _v3_payload(
598 envelope: StraikerWebhookRequest,
599 inputs: GenericGuardrailAPIInputs,
600 request_data: Mapping[str, object],
601 input_type: Literal["request", "response"],
602) -> Mapping[str, object]:
603 """The /api/v3/detect body for one phase of a turn, the unified Kong plugin's contract.
605 Request phase: the provider body itself. Response phase: the answer beside the request
606 it answers, `{straiker_phase, sse, model, request}`, which is how Straiker classifies a
607 tool call the model just made. Straiker parses either and derives prompt, answer, agent
608 and archetype from the traffic; nothing is pre-digested here. Identity and session ride
609 on both phases the way Kong sends them.
610 """
611 context: Final = envelope.context
612 request_body: Final = _v3_request_body(request_data)
613 answer_json: Final = _v3_answer_json(inputs, request_data, context.model) if input_type == "response" else None
614 phase: Final = (
615 tuple(request_body.items())
616 if input_type == "request"
617 else (
618 ("straiker_phase", V3_RESPONSE_PHASE),
619 ("model", context.model),
620 ("request", request_body),
621 *((("sse", answer_json),) if answer_json is not None else ()),
622 )
623 )
624 session: Final = _v3_session_id(envelope, request_data, request_body)
625 user: Final = _v3_user(envelope)
626 return _frozen(
627 (
628 *phase,
629 *((("session_id", session),) if session else ()),
630 *(
631 (("original", _frozen((("processed", _frozen((("Meta", _frozen((("user", user),))),))),))),)
632 if user
633 else ()
634 ),
635 )
636 )
639def _v3_conversation_prefixes(request_body: Mapping[str, object]) -> tuple[str, ...]:
640 """A fingerprint of the conversation after each of its messages, first to last.
642 The last one names the conversation as sent; the earlier ones let a request that
643 carries a blocked exchange as its history be recognised, not only an exact resend.
644 A `prompt` or a string `input` has one fingerprint.
645 """
646 messages: Final = _v3_messages(request_body)
647 if messages:
648 digest: Final = hashlib.sha256()
650 def after(message: object) -> str:
651 digest.update(json.dumps(message, sort_keys=True, default=str).encode("utf-8"))
652 digest.update(b"\x1e")
653 return digest.copy().hexdigest()
655 return tuple(after(message) for message in messages)
656 plain: Final = request_body.get("input") if "input" in request_body else request_body.get("prompt")
657 if plain is None:
658 return ()
659 return (hashlib.sha256(json.dumps(plain, sort_keys=True, default=str).encode("utf-8")).hexdigest(),)
662def _v3_session_id(
663 envelope: StraikerWebhookRequest,
664 request_data: Mapping[str, object],
665 request_body: Mapping[str, object],
666) -> str | None:
667 """A stable id for the conversation, in Kong's order of precedence.
669 Claude Code names its session on the wire and that wins. Then the session LiteLLM
670 resolved from its own metadata. Then, for a conversation that states none, a hash of
671 the principal, the system prompt and the first message: a chat client replays the
672 whole conversation on every turn, so that triple is constant for its lifetime and
673 groups the turns. A fresh synthetic id per request would group nothing.
675 The principal is in the hash because Straiker skips turns it has already scored for a
676 session. Two users who open with the same words are two conversations; hashed on the
677 words alone they shared one session, and the second user's copy of an attack came
678 back as a replay, unscored and allowed (measured 2026-09-20).
679 """
680 supplied: Final = _request_header(request_data, V3_SESSION_HEADER)
681 if supplied:
682 return supplied
683 if envelope.context.session_id:
684 return envelope.context.session_id
685 conversation: Final = f"{_v3_system_text(request_body) or ''}\0{_v3_first_message_text(request_body)}"
686 if conversation == "\0":
687 return None
688 seed: Final = f"{_v3_user(envelope) or ''}\0{conversation}"
689 return V3_DERIVED_SESSION_PREFIX + hashlib.sha256(seed.encode("utf-8")).hexdigest()[:32]
692_V3_PREAMBLE_ROLES: Final = frozenset({"system", "developer"})
695def _v3_message_text(message: object) -> str:
696 """Every text block of a message, so a turn that opens with an image or a document still
697 seeds on what the user wrote."""
698 content: Final = message.get("content") if isinstance(message, Mapping) else None
699 if isinstance(content, str):
700 return content
701 if isinstance(content, (list, tuple)):
702 return "\n".join(
703 str(block["text"]) for block in content if isinstance(block, Mapping) and isinstance(block.get("text"), str)
704 )
705 return ""
708def _v3_messages(request_body: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
709 messages: Final = request_body.get("messages") or request_body.get("input")
710 if isinstance(messages, (list, tuple)):
711 return tuple(message for message in messages if isinstance(message, Mapping))
712 return ()
715def _v3_system_text(request_body: Mapping[str, object]) -> str | None:
716 """The preamble, wherever the API puts it: Anthropic's `system`, the Responses API's
717 `instructions`, or the leading system or developer message of an OpenAI chat body."""
718 system: Final = request_body.get("system")
719 if isinstance(system, str):
720 return system
721 if system is not None:
722 return json.dumps(system, default=str)
723 instructions: Final = request_body.get("instructions")
724 if isinstance(instructions, str):
725 return instructions
726 preamble: Final = next((m for m in _v3_messages(request_body) if m.get("role") in _V3_PREAMBLE_ROLES), None)
727 return _v3_message_text(preamble) if preamble is not None else None
730def _v3_first_message_text(request_body: Mapping[str, object]) -> str:
731 """What the user first said: the first `user` message, never the system prompt that an
732 OpenAI chat body carries as `messages[0]`, else a Responses `input` string, else `prompt`."""
733 first_user: Final = next((m for m in _v3_messages(request_body) if m.get("role") == "user"), None)
734 if first_user is not None:
735 return _v3_message_text(first_user)
736 plain: Final = (
737 request_body.get("input") if isinstance(request_body.get("input"), str) else request_body.get("prompt")
738 )
739 return plain if isinstance(plain, str) else ""
742def _v3_user(envelope: StraikerWebhookRequest) -> str | None:
743 """Who is asking: the key's own user first, then the end user the request named.
745 The key is the authenticated principal, the way a Kong consumer is, so a per-user key
746 names the person even when the client packs something else into the body. Claude Code
747 packs a hashed account-and-session token into `metadata.user_id`, which is what the end
748 user resolves to when nothing better is set; it is a session, not a person, and only
749 surfaces when the key names nobody. A master-key call resolves to LiteLLM's
750 `default_user_id`; sent as an identity it would become one.
751 """
752 identity: Final = envelope.identity
753 for candidate in (identity.litellm_user_email, identity.litellm_user_id, identity.end_user_id):
754 real = _real_identity(candidate)
755 if real:
756 return real
757 return None
760def _v3_client_from_user_agent(request_data: Mapping[str, object]) -> tuple[str, str] | None:
761 """`(client, agent name)` for a User-Agent this gateway recognises, else None."""
762 user_agent: Final = (_request_header(request_data, "user-agent") or "").lower()
763 return next(
764 (
765 (client, f"{display} ({V3_GATEWAY_NAME})")
766 for prefix, client, display in _V3_CLIENT_BY_USER_AGENT
767 if user_agent.startswith(prefix)
768 ),
769 None,
770 )
773def _v3_headers(
774 request_data: Mapping[str, object],
775 agent_ref: str | None = None,
776 client: str | None = None,
777 format_hint: str | None = None,
778) -> Mapping[str, str]:
779 """Per-call routing hints, the unified Kong plugin's set. All optional.
781 `x-s6r-agent` names ONE application when a gateway fronts several: the route's
782 `agent_ref`, else the caller's own header, else the agent this gateway names from the
783 User-Agent. The operator's value comes first because the header is caller-supplied, and
784 honouring it over a pinned route would let any key file its traffic under another
785 application's agent and controls. `x-s6r-client` is the route's `client` config, else
786 the client the User-Agent names. `x-s6r-format` comes from config alone. Claude Code's own session header is
787 forwarded when the client sent it, which is how a coding session groups the way the
788 native hook would.
789 """
790 session: Final = _request_header(request_data, V3_SESSION_HEADER)
791 recognised: Final = _v3_client_from_user_agent(request_data)
792 agent: Final = (
793 agent_ref or _request_header(request_data, V3_AGENT_HEADER) or (recognised[1] if recognised else None)
794 )
795 named_client: Final = client or (recognised[0] if recognised else None)
796 candidates: Final = (
797 (V3_SESSION_HEADER, session),
798 (V3_AGENT_HEADER, agent),
799 (V3_CLIENT_HEADER, named_client),
800 (V3_FORMAT_HEADER, format_hint),
801 )
802 return MappingProxyType({name: value for name, value in candidates if value})
805def _v3_decision(body: Mapping[str, object]) -> tuple[str | None, Mapping[str, object]]:
806 """``(decision, verdict)``: the enforceable decision and the object carrying it.
808 Straiker answers in two envelopes. A relayed body gets the hook contract,
809 `hookSpecificOutput.permissionDecision`, with the flat fields nested under `straiker`;
810 a flat call answers `action` at the top level. Reading only one of them would silently
811 make block mode a no-op on the other.
812 """
813 nested: Final = body.get("straiker")
814 verdict: Final = nested if isinstance(nested, Mapping) else body
815 hook: Final = body.get("hookSpecificOutput")
816 decision: Final = hook.get("permissionDecision") if isinstance(hook, Mapping) else None
817 if isinstance(decision, str) and decision:
818 return decision.lower(), verdict
819 action: Final = verdict.get("action")
820 return (action.lower() if isinstance(action, str) and action else None), verdict
823def _v3_response(body: Mapping[str, object]) -> StraikerWebhookResponse:
824 """Map a v3 verdict onto the action the guardrail already acts on.
826 A detect-mode control fires into `controls` without changing the decision, so it
827 correctly reads NONE. `blocked_by` is the block-mode subset and is honoured even if a
828 build answers it without flipping the decision.
829 """
830 decision, verdict = _v3_decision(body)
831 raw_blocked_by: Final = verdict.get("blocked_by")
832 blocked_by: Final = tuple(sorted(str(c) for c in raw_blocked_by)) if isinstance(raw_blocked_by, list) else ()
833 blocked: Final = decision in V3_BLOCK_DECISIONS or bool(blocked_by)
834 stated: Final = (verdict.get("block_message"), verdict.get("deny_reason"), body.get("stopReason"))
835 reason: Final = (
836 next(
837 (text.strip() for text in stated if isinstance(text, str) and text.strip()),
838 f"Straiker blocked this turn: {', '.join(blocked_by) or 'policy'}",
839 )
840 if blocked
841 else None
842 )
843 return StraikerWebhookResponse(
844 action="BLOCKED" if blocked else "NONE",
845 blocked_reason=reason,
846 blocked_by=blocked_by,
847 turnId=_as_optional_str(verdict.get("turn_id")) or _as_optional_str(body.get("turn_id")),
848 )
851class StraikerGuardrail(CustomGuardrail):
852 @staticmethod
853 def get_config_model() -> type[GuardrailConfigModel]:
854 return StraikerGuardrailConfigModel
856 @classmethod
857 def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
858 return [
859 GuardrailEventHooks.pre_call,
860 GuardrailEventHooks.post_call,
861 ]
863 def __init__(
864 self,
865 api_key: str,
866 api_base: str = DEFAULT_API_BASE,
867 api_version: Literal["v1", "v3"] | None = None,
868 agent_ref: str | None = None,
869 client: str | None = None,
870 format_hint: Literal["anthropic.messages", "openai.chat"] | None = None,
871 source: str = "LiteLLM Gateway",
872 timeout: float = 5.0,
873 max_retries: int = 2,
874 initial_backoff: float = 0.1,
875 max_backoff: float = 2.0,
876 unreachable_fallback: Literal["fail_open", "fail_closed"] = "fail_closed",
877 fail_on_error: bool = True,
878 max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES,
879 custom_headers: dict[str, str] | None = None,
880 metadata: dict[str, str] | None = None,
881 verbose: bool = False,
882 async_handler: httpx.AsyncClient | None = None,
883 **kwargs: object,
884 ) -> None:
885 if not api_key:
886 raise ValueError("api_key must be non-empty")
887 if unreachable_fallback not in ("fail_open", "fail_closed"):
888 raise ValueError(f"unreachable_fallback must be 'fail_open' or 'fail_closed'; got {unreachable_fallback!r}")
889 if api_version is None:
890 # The key names the platform: a v3 integration key cannot call v1 and a v1
891 # collection key cannot call v3, so an unset version follows the key.
892 api_version = "v3" if api_key.startswith(V3_KEY_PREFIX) else "v1"
893 if api_version not in ("v1", "v3"):
894 raise ValueError(f"api_version must be 'v1' or 'v3'; got {api_version!r}")
896 self.api_key = api_key
897 self.api_base = api_base.rstrip("/")
898 self.api_version = api_version
899 self.agent_ref = _as_optional_str(agent_ref)
900 self.client = _as_optional_str(client)
901 if format_hint is not None and format_hint not in ("anthropic.messages", "openai.chat"):
902 raise ValueError(f"format_hint must be 'anthropic.messages' or 'openai.chat'; got {format_hint!r}")
903 self.format_hint = format_hint
904 # Blocked conversations by session, so a resend or a conversation grown past a blocked
905 # turn is blocked again here: Straiker de-duplicates turns it has already scored per
906 # session and answers a replay `allow`, whatever the original verdict was (measured
907 # 2026-09-20). Per process; a replica that did not see the block asks Straiker.
908 self._v3_blocked_turns = InMemoryCache(
909 max_size_in_memory=V3_BLOCKED_TURN_MEMORY, default_ttl=V3_BLOCKED_TURN_TTL_SECONDS
910 )
911 self.source = source
912 self.timeout = float(timeout)
913 self.max_retries = max(0, int(max_retries))
914 self.initial_backoff = max(0.0, float(initial_backoff))
915 self.max_backoff = max(self.initial_backoff, float(max_backoff))
916 self.unreachable_fallback = unreachable_fallback
917 self.fail_on_error = fail_on_error
918 self.max_payload_bytes = int(max_payload_bytes)
919 self.custom_headers = dict(custom_headers) if custom_headers else {}
920 self.default_metadata = dict(metadata) if metadata else {}
921 self.verbose = bool(verbose)
923 self.streaming_end_of_stream_only = True
924 self.streaming_buffer_until_moderated = True
926 self.async_handler = async_handler or get_async_httpx_client(
927 llm_provider=httpxSpecialProvider.GuardrailCallback,
928 )
930 kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks()))
931 super().__init__(**kwargs)
933 self.configured_modes = _configured_modes(self.event_hook)
935 def _webhook_url(self) -> str:
936 return f"{self.api_base}{V3_DETECT_PATH if self.api_version == 'v3' else WEBHOOK_PATH}"
938 def _headers(self) -> dict[str, str]:
939 reserved: Final = {"authorization", "content-type", "x-straiker-webhook-format"}
940 extra: Final = {k: v for k, v in self.custom_headers.items() if k.lower() not in reserved}
941 headers: Final = {
942 "Authorization": f"Bearer {self.api_key}",
943 "Content-Type": "application/json",
944 }
945 if self.api_version != "v3":
946 headers["X-Straiker-Webhook-Format"] = "litellm"
947 return {**headers, **extra}
949 def _build_application(self, request_data: dict) -> StraikerWebhookApplication:
950 meta: Final = _merged_metadata(request_data)
951 agent_id: Final = _as_optional_str(meta.get("agent_id"))
952 return StraikerWebhookApplication(
953 source=agent_id or self.source,
954 name=_as_optional_str(meta.get("app_name")),
955 )
957 def _build_context(
958 self,
959 request_data: dict,
960 model: str | None,
961 logging_obj: LiteLLMLoggingObj | None,
962 ) -> StraikerWebhookContext:
963 return StraikerWebhookContext(
964 call_surface=_resolve_call_surface(logging_obj, request_data),
965 mode=self.configured_modes,
966 model=model,
967 model_provider=_resolve_provider(request_data, model),
968 destination=_resolve_destination(request_data),
969 session_id=get_session_id_from_request_data(request_data),
970 litellm_call_id=getattr(logging_obj, "litellm_call_id", None) if logging_obj else None,
971 litellm_trace_id=getattr(logging_obj, "litellm_trace_id", None) if logging_obj else None,
972 litellm_version=litellm_version,
973 )
975 def _build_envelope(
976 self,
977 *,
978 inputs: GenericGuardrailAPIInputs,
979 request_data: dict,
980 input_type: Literal["request", "response"],
981 logging_obj: LiteLLMLoggingObj | None,
982 ) -> StraikerWebhookRequest:
983 model: Final = inputs.get("model") or request_data.get("model")
984 call_id: Final = getattr(logging_obj, "litellm_call_id", None) if logging_obj else None
985 event_id: Final = f"{call_id or 'litellm'}:{input_type}"
987 content: Final = StraikerWebhookContent(
988 texts=list(inputs.get("texts") or []),
989 images=list(inputs.get("images") or []),
990 structured_messages=_opaque_dict_list(inputs.get("structured_messages")),
991 tools=_opaque_dict_list(inputs.get("tools")),
992 tool_calls=_opaque_dict_list(inputs.get("tool_calls")),
993 )
995 if input_type == "request":
996 event = StraikerWebhookEvent(type="pre_call", id=event_id)
997 return StraikerWebhookRequest(
998 event=event,
999 request=content,
1000 context=self._build_context(request_data, model, logging_obj),
1001 identity=_extract_identity(request_data),
1002 application=self._build_application(request_data),
1003 metadata=_build_webhook_metadata(request_data, self.default_metadata),
1004 )
1006 response_obj: Final = request_data.get("response")
1007 content.finish_reason = _response_finish_reason(response_obj)
1008 request_content: Final = StraikerWebhookContent(
1009 structured_messages=_opaque_dict_list(_request_structured_messages(request_data)),
1010 )
1011 phase: Final[Literal["none", "assembled"]] = "assembled" if _is_streamed_request(request_data) else "none"
1012 event = StraikerWebhookEvent(type="post_call", id=event_id, stream=StraikerWebhookStream(phase=phase))
1013 return StraikerWebhookRequest(
1014 event=event,
1015 request=request_content,
1016 response=content,
1017 context=self._build_context(request_data, model, logging_obj),
1018 identity=_extract_identity(request_data),
1019 application=self._build_application(request_data),
1020 usage=_build_usage(response_obj),
1021 metadata=_build_webhook_metadata(request_data, self.default_metadata),
1022 )
1024 async def _post_webhook(
1025 self, payload: Mapping[str, object], headers: Mapping[str, str] | None = None
1026 ) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
1027 try:
1028 body: Final = json.dumps(payload, default=_json_default).encode("utf-8")
1029 except (TypeError, ValueError, OverflowError) as error:
1030 return None, _WebhookFailure(f"request serialization failed: {error}", is_unreachable=False)
1031 body_bytes: Final = len(body)
1032 if body_bytes > self.max_payload_bytes:
1033 return None, _WebhookFailure(
1034 f"payload {body_bytes}B exceeds max_payload_bytes {self.max_payload_bytes}",
1035 is_unreachable=False,
1036 )
1038 url: Final = self._webhook_url()
1039 merged_headers: Final = {**self._headers(), **(headers or {})}
1040 attempts: Final = self.max_retries + 1
1041 last_failure: _WebhookFailure | None = None
1043 if self.verbose:
1044 verbose_proxy_logger.info(
1045 json.dumps(
1046 {
1047 "event": "straiker.webhook_request",
1048 "url": url,
1049 "bytes": body_bytes,
1050 "payload": payload,
1051 },
1052 default=_json_default,
1053 )
1054 )
1056 for attempt in range(attempts):
1057 parsed, last_failure = await self._attempt(url, body, merged_headers)
1058 if last_failure is None or not last_failure.retryable:
1059 return parsed, last_failure
1060 if attempt < attempts - 1:
1061 backoff = min(self.initial_backoff * (2**attempt), self.max_backoff)
1062 await asyncio.sleep(random.uniform(0, backoff))
1064 return None, last_failure or _WebhookFailure("unknown error", is_unreachable=True)
1066 async def _attempt(
1067 self, url: str, body: bytes, headers: dict[str, str]
1068 ) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
1069 try:
1070 resp: Final = await self.async_handler.post(url, content=body, headers=headers, timeout=self.timeout)
1071 except httpx.HTTPStatusError as status_error:
1072 return None, _status_failure(status_error.response.status_code, _error_response_text(status_error.response))
1073 except (httpx.RequestError, asyncio.TimeoutError, Timeout) as e:
1074 return None, _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=True, retryable=True)
1075 except (json.JSONDecodeError, TypeError, ValueError) as e:
1076 return None, _WebhookFailure(f"{type(e).__name__}: {e}", is_unreachable=False)
1077 if resp is None:
1078 return None, _WebhookFailure("no response", is_unreachable=True, retryable=True)
1079 if resp.status_code == 200:
1080 return self._parse_verdict(resp)
1081 return None, _status_failure(resp.status_code, resp.text)
1083 def _parse_verdict(self, resp: httpx.Response) -> tuple[StraikerWebhookResponse | None, _WebhookFailure | None]:
1084 try:
1085 body: Final = resp.json()
1086 if not isinstance(body, Mapping):
1087 return None, _WebhookFailure(
1088 f"invalid response schema: expected an object, got {type(body).__name__}", is_unreachable=False
1089 )
1090 parsed: Final = (
1091 _v3_response(body) if self.api_version == "v3" else StraikerWebhookResponse.model_validate(body)
1092 )
1093 except (ValidationError, json.JSONDecodeError) as ve:
1094 return None, _WebhookFailure(f"invalid response schema: {ve}", is_unreachable=False)
1095 if self.verbose:
1096 verbose_proxy_logger.info(
1097 json.dumps(
1098 {"event": "straiker.webhook_response", "status_code": resp.status_code, "body": body},
1099 default=_json_default,
1100 )
1101 )
1102 return parsed, None
1104 def _record(
1105 self,
1106 *,
1107 request_data: dict,
1108 logging_obj: LiteLLMLoggingObj | None,
1109 parsed: StraikerWebhookResponse,
1110 ) -> None:
1111 if not self.verbose:
1112 return
1113 response_obj: Final = request_data.get("response")
1114 hidden: Final = getattr(response_obj, "_hidden_params", None)
1115 if isinstance(hidden, dict):
1116 straiker_hidden: Final = hidden.setdefault("straiker", {})
1117 if isinstance(straiker_hidden, dict):
1118 straiker_hidden.update({"action": parsed.action, "turn_id": parsed.turn_id})
1120 def _fail(
1121 self,
1122 *,
1123 inputs: GenericGuardrailAPIInputs,
1124 request_data: dict,
1125 input_type: Literal["request", "response"],
1126 error: str,
1127 is_unreachable: bool,
1128 ) -> GenericGuardrailAPIInputs:
1129 fail_open: Final = (is_unreachable and self.unreachable_fallback == "fail_open") or not self.fail_on_error
1130 verbose_proxy_logger.error(
1131 json.dumps(
1132 {
1133 "event": "straiker.error",
1134 "input_type": input_type,
1135 "error": error,
1136 "fail_open": fail_open,
1137 },
1138 default=_json_default,
1139 )
1140 )
1141 if fail_open:
1142 return inputs
1143 self._block(
1144 request_data=request_data,
1145 input_type=input_type,
1146 message=f"Straiker detection unavailable: {error}",
1147 )
1149 def _block(
1150 self,
1151 *,
1152 request_data: dict,
1153 input_type: Literal["request", "response"],
1154 message: str,
1155 blocked_content: bool = False,
1156 ) -> NoReturn:
1157 if input_type == "request":
1158 raise GuardrailRaisedException(
1159 guardrail_name=self.guardrail_name or GUARDRAIL_NAME,
1160 message=message,
1161 should_wrap_with_default_message=False,
1162 blocked_content=blocked_content,
1163 )
1164 raise ModifyResponseException(
1165 message=message,
1166 model=request_data.get("model", "unknown") or "unknown",
1167 request_data=request_data,
1168 guardrail_name=self.guardrail_name or GUARDRAIL_NAME,
1169 original_response=request_data.get("response"),
1170 )
1172 @staticmethod
1173 def _intervened_inputs(
1174 inputs: GenericGuardrailAPIInputs,
1175 parsed: StraikerWebhookResponse,
1176 ) -> GenericGuardrailAPIInputs:
1177 return_inputs: Final[GenericGuardrailAPIInputs] = {}
1178 return_inputs.update(inputs)
1179 if parsed.texts is not None:
1180 return_inputs["texts"] = parsed.texts
1181 return return_inputs
1183 async def _apply_v3(
1184 self,
1185 *,
1186 inputs: GenericGuardrailAPIInputs,
1187 request_data: dict,
1188 input_type: Literal["request", "response"],
1189 logging_obj: LiteLLMLoggingObj | None,
1190 ) -> GenericGuardrailAPIInputs:
1191 """One phase of a turn against /api/v3/detect: relay, read the decision, enforce."""
1192 try:
1193 envelope: Final = self._build_envelope(
1194 inputs=inputs,
1195 request_data=request_data,
1196 input_type=input_type,
1197 logging_obj=logging_obj,
1198 )
1199 payload: Final = _v3_payload(envelope, inputs, request_data, input_type)
1200 headers: Final = _v3_headers(request_data, self.agent_ref, self.client, self.format_hint)
1201 request_body: Final = _v3_request_body(request_data)
1202 # The memory is scoped by the session, else by the principal; a request that has
1203 # neither is never remembered, so no two callers can share a block.
1204 scope: Final = _v3_session_id(envelope, request_data, request_body) or _v3_user(envelope) or ""
1205 prefixes: Final = _v3_conversation_prefixes(request_body) if scope else ()
1206 except (ValidationError, TypeError, ValueError) as error:
1207 return self._fail(
1208 inputs=inputs,
1209 request_data=request_data,
1210 input_type=input_type,
1211 error=str(error),
1212 is_unreachable=False,
1213 )
1215 replayed: Final = self._v3_replayed_block(scope, prefixes) if input_type == "request" else None
1216 if replayed is not None:
1217 self._block(request_data=request_data, input_type=input_type, message=replayed, blocked_content=True)
1219 parsed, failure = await self._post_webhook(payload, headers)
1220 if failure is not None or parsed is None:
1221 return self._fail(
1222 inputs=inputs,
1223 request_data=request_data,
1224 input_type=input_type,
1225 error=failure.message if failure is not None else "empty response from Straiker",
1226 is_unreachable=failure.is_unreachable if failure is not None else False,
1227 )
1228 self._record(request_data=request_data, logging_obj=logging_obj, parsed=parsed)
1229 if parsed.action == "BLOCKED":
1230 message: Final = parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE
1231 # Only a block that names a control is remembered. The same words are the same
1232 # attack tomorrow, but a block that comes from state -- an engaged kill switch,
1233 # a governance action -- is lifted by an administrator, and a remembered copy
1234 # would keep refusing a conversation the platform now allows.
1235 if prefixes and parsed.blocked_by:
1236 self._v3_blocked_turns.set_cache(f"{scope}\0{prefixes[-1]}", message)
1237 self._block(request_data=request_data, input_type=input_type, message=message, blocked_content=True)
1238 return inputs
1240 def _v3_replayed_block(self, scope: str, prefixes: tuple[str, ...]) -> str | None:
1241 """The block message a conversation already earned, when this request repeats or
1242 extends a conversation this process blocked in the same scope (session or principal)."""
1243 for prefix in prefixes:
1244 message: str | None = self._v3_blocked_turns.get_cache(f"{scope}\0{prefix}")
1245 if message is not None:
1246 if self.verbose:
1247 verbose_proxy_logger.info(
1248 json.dumps({"event": "straiker.replay_blocked", "scope": scope, "prefix": prefix})
1249 )
1250 return message
1251 return None
1253 @log_guardrail_information
1254 async def apply_guardrail(
1255 self,
1256 inputs: GenericGuardrailAPIInputs,
1257 request_data: dict,
1258 input_type: Literal["request", "response"],
1259 logging_obj: LiteLLMLoggingObj | None = None,
1260 ) -> GenericGuardrailAPIInputs:
1261 if self.api_version == "v3":
1262 return await self._apply_v3(
1263 inputs=inputs, request_data=request_data, input_type=input_type, logging_obj=logging_obj
1264 )
1265 try:
1266 envelope: Final = self._build_envelope(
1267 inputs=inputs,
1268 request_data=request_data,
1269 input_type=input_type,
1270 logging_obj=logging_obj,
1271 )
1272 payload: Final = envelope.model_dump(mode="json", exclude_none=True)
1273 except (ValidationError, TypeError, ValueError) as error:
1274 return self._fail(
1275 inputs=inputs,
1276 request_data=request_data,
1277 input_type=input_type,
1278 error=str(error),
1279 is_unreachable=False,
1280 )
1282 parsed, failure = await self._post_webhook(payload)
1283 if failure is not None:
1284 return self._fail(
1285 inputs=inputs,
1286 request_data=request_data,
1287 input_type=input_type,
1288 error=failure.message,
1289 is_unreachable=failure.is_unreachable,
1290 )
1292 if parsed is None:
1293 return self._fail(
1294 inputs=inputs,
1295 request_data=request_data,
1296 input_type=input_type,
1297 error="empty response from Straiker",
1298 is_unreachable=False,
1299 )
1300 self._record(request_data=request_data, logging_obj=logging_obj, parsed=parsed)
1302 if parsed.schema_version is not None and parsed.schema_version != STRAIKER_WEBHOOK_SCHEMA_VERSION:
1303 verbose_proxy_logger.warning(
1304 json.dumps(
1305 {
1306 "event": "straiker.schema_drift",
1307 "expected": STRAIKER_WEBHOOK_SCHEMA_VERSION,
1308 "received": parsed.schema_version,
1309 }
1310 )
1311 )
1313 if parsed.action == "BLOCKED":
1314 self._block(
1315 request_data=request_data,
1316 input_type=input_type,
1317 message=parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE,
1318 blocked_content=True,
1319 )
1320 if parsed.action == "GUARDRAIL_INTERVENED":
1321 is_streamed_response: Final = input_type == "response" and _is_streamed_request(request_data)
1322 if parsed.texts is None or is_streamed_response:
1323 self._block(
1324 request_data=request_data,
1325 input_type=input_type,
1326 message=parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE,
1327 blocked_content=True,
1328 )
1329 return self._intervened_inputs(inputs, parsed)
1330 return inputs