Luthien Policy Authoring Guide
Luthien policies intercept and transform Anthropic API traffic flowing through the gateway. Policies are singletons created once at startup, shared across all concurrent requests. They implement four lifecycle hooks called by the executor around backend I/O.
Policy Class Hierarchy — Pick the Right Base
| Base class | Inherit when... | Override |
|---|---|---|
BasePolicy + AnthropicHookPolicy |
Full control over every hook | Any of the 4 hooks |
TextModifierPolicy |
Transforming response text (streaming-aware) | modify_text(), optionally extra_text() |
SimplePolicy |
Buffering is acceptable; transform complete content | simple_on_response_content(), simple_on_request(), simple_on_anthropic_tool_call() |
NoOpPolicy |
Pass-through base for config-only demos | Nothing (inherits passthrough) |
Decision flowchart:
- Only modifying text content? ->
TextModifierPolicy(streaming-friendly, 1 method) - Need complete content blocks (e.g., tool calls)? ->
SimplePolicy(buffers, 3 methods) - Need per-event streaming control? ->
BasePolicy+AnthropicHookPolicy(4 hooks)
The Four Lifecycle Hooks
on_anthropic_request(request, context) -> request # Transform before backend call
|
[Backend call happens]
|
on_anthropic_response(response, context) -> response # Non-streaming only
|
# Streaming path:
on_anthropic_stream_event(event, context) -> list[event] # Per-event (filter=[], duplicate=multi)
on_anthropic_stream_complete(context) -> list[emission] # After stream ends, inject extras
AnthropicHookPolicy provides passthrough defaults for all four — override only what's needed.
Request-Scoped State via PolicyContext
Policies are singletons. Never store request data on self.
@dataclass
class _MyState:
buffer: str = ""
count: int = 0
class MyPolicy(BasePolicy, AnthropicHookPolicy):
async def on_anthropic_stream_event(self, event, context):
state = context.get_request_state(self, _MyState, _MyState)
state.buffer += "..." # safe — isolated per request per policy
return [event]
Key PolicyContext facilities:
get_request_state(owner, expected_type, factory)— typed, collision-free per-policy statepop_request_state(owner, type)— remove and return (cleanup)record_event(event_type, data)— fire-and-forget observabilityspan(name, attrs)— OpenTelemetry context managersession_id— conversation session identifiercredential_manager— resolve auth credentialsfor_testing(...)— create test-friendly contexts
Configuration with Pydantic
Define a Pydantic BaseModel for config. Use _init_config() to handle all input forms (None/dict/model):
class MyConfig(BaseModel):
threshold: float = Field(default=0.5, ge=0.0, le=1.0)
api_key: str | None = Field(default=None, json_schema_extra={"format": "password"})
class MyPolicy(BasePolicy, AnthropicHookPolicy):
def __init__(self, config: MyConfig | None = None):
self.config = self._init_config(config, MyConfig)
# Derived immutable state is fine on self:
self._threshold = self.config.threshold # scalar — OK
YAML config:
policy:
class: "luthien_proxy.policies.my_policy:MyPolicy"
config:
threshold: 0.8
Critical Rules
- No mutable containers on
self—freeze_configured_state()rejectslist,dict,seton the policy instance. Usetuple/frozensetfor config,PolicyContextfor request state. - Content blocks before message_delta — all
content_block_*events must precedeRawMessageDeltaEventin streaming. Violating this corrupts sessions. - Thinking blocks first —
thinking/redacted_thinkingblocks must come beforetextblocks. - Single finish_reason — emit
finish_reasononce in the final chunk, not per tool call. - Preflight detection — Claude Code sends probe requests (
max_tokens=1) sharingsession_id. Useis_first_turn()(re-evaluated per request), not session-level counters. - Override
short_policy_name—BasePolicy.short_policy_namereturns the class name by default. Override it for a short human-readable identifier (e.g.,"NoOp","ToolJudge") used in logs and the admin UI.
Streaming Event Types
Events from anthropic.types:
RawContentBlockStartEvent— block begins (has.content_blockwith type info)RawContentBlockDeltaEvent— content chunk (.delta:TextDelta,InputJSONDelta, etc.)RawContentBlockStopEvent— block completeRawMessageDeltaEvent— message-level metadata (stop_reason, usage)RawMessageStopEvent— message complete
Return values from on_anthropic_stream_event:
[event]— pass through[]— filter/suppress (buffer it)[event1, event2]— expand/inject
Testing Policies
Mirror source path: src/.../my_policy.py -> tests/.../unit_tests/policies/test_my_policy.py
from luthien_proxy.policy_core.policy_context import PolicyContext
@pytest.fixture
def policy():
return MyPolicy(config=MyConfig(threshold=0.5))
class TestMyPolicyProtocol:
def test_implements_interface(self, policy):
assert isinstance(policy, AnthropicExecutionInterface)
assert isinstance(policy, BasePolicy)
class TestMyPolicyResponse:
@pytest.mark.asyncio
async def test_transforms_content(self):
policy = MyPolicy()
ctx = PolicyContext.for_testing(transaction_id="test")
response: AnthropicResponse = {
"id": "msg_test", "type": "message", "role": "assistant",
"content": [{"type": "text", "text": "hello"}],
"model": "test", "stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
}
result = await policy.on_anthropic_response(response, ctx)
assert result["content"][0]["text"] == "EXPECTED"
Run: uv run pytest tests/luthien_proxy/unit_tests/policies/test_my_policy.py
File Placement
- Policy module:
src/luthien_proxy/policies/<name>_policy.py - Tests:
tests/luthien_proxy/unit_tests/policies/test_<name>_policy.py - Config:
config/policy_config.yaml(uncomment/add entry) - Export: add to
src/luthien_proxy/policies/__init__.pyif needed
Additional Resources
For detailed patterns, streaming internals, and gotchas, consult:
references/patterns.md— complete examples of each policy tier (passthrough, text modifier, buffered, streaming, judge-based)references/gotchas.md— streaming protocol violations, mutable state bugs, event ordering, and preflight edge casesreferences/uppercase_policy.py— simplest possible TextModifierPolicy (working example)references/first_turn_banner_policy.py— SimplePolicy with request-scoped state and first-turn detection (working example)
Source: LuthienResearch/luthien-proxy — distributed by TomeVault.