Add advisors module: custom logging, PII redaction and token-budget advisors

Tests pin down chain ordering (including ties), BaseAdvisor stream behaviour, redaction order versus memory and logging, the tool loop, and how a refusal surfaces on calls, streams and over HTTP.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
This commit is contained in:
Claude
2026-09-24 16:21:19 +00:00
parent 823f6fac5b
commit 527f4ba7ff
59 changed files with 2805 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Advisor chain: what decides who runs first
registered as C300, A100, B200 (the number is HIGHEST_PRECEDENCE + n)
call: A100> B200> C300> model C300< B200< A100<
stream: A100> B200> C300> model C300< B200< A100<
plus one advisor added on the request with .advisors(...) at n=150:
call: A100> R150> B200> C300> model C300< B200< R150< A100<
+6
View File
@@ -0,0 +1,6 @@
# Two advisors with the same order number
registered X, Y -> Y> X> model X< Y<
registered Y, X -> X> Y> model Y< X<
For equal numbers the advisor registered LAST runs first (outermost).
+16
View File
@@ -0,0 +1,16 @@
# What is actually in the chain
one custom advisor at HIGHEST_PRECEDENCE + 100; the chain a call runs through:
Dump HIGHEST_PRECEDENCE + 100
Tool Calling Advisor HIGHEST_PRECEDENCE + 300
call LOWEST_PRECEDENCE
and the chain a stream runs through:
Dump HIGHEST_PRECEDENCE + 100
Tool Calling Advisor HIGHEST_PRECEDENCE + 300
stream LOWEST_PRECEDENCE
the same, with ToolCallingAdvisor.builder().build() added by hand:
Dump HIGHEST_PRECEDENCE + 100
Tool Calling Advisor HIGHEST_PRECEDENCE + 300
call LOWEST_PRECEDENCE
@@ -0,0 +1,11 @@
# BaseAdvisor on a call and on a stream
call: before x1, after x1
before ran on thread: the caller's thread
stream: the model streamed 5 chunks: one |two |thre|e fo|ur
before x1, after x1
after saw only: "ur"
before ran on thread: a boundedElastic worker, not the caller's thread
stream whose last chunk has no finish reason: before x1, after x0
+13
View File
@@ -0,0 +1,13 @@
# LoggingAdvisor: a call and a stream
call, content logging off (the default):
[LoggingAdvisor] request messages=2 roles=SU
[LoggingAdvisor] response chars=14 tokens=12+3 took 5 ms
stream:
[LoggingAdvisor] request messages=2 roles=SU
[LoggingAdvisor] complete 3 chunks, 14 chars tokens=12+3 took 5 ms
call, content logging on:
[LoggingAdvisor] request messages=1 roles=U last="Where is my refund?"
[LoggingAdvisor] response chars=14 tokens=5+3 text="Refund issued." took 5 ms
+5
View File
@@ -0,0 +1,5 @@
# LoggingAdvisor when the model call fails
caller got: IllegalStateException: provider returned 503
[LoggingAdvisor] request messages=1 roles=U
[LoggingAdvisor] failed IllegalStateException after 5 ms
+8
View File
@@ -0,0 +1,8 @@
# PiiRedactionAdvisor on a call
caller sends: Hi, I'm Priya. Email [email protected] or call +91 98765 43210. Card 4111 1111 1111 1111, order 1234 5678 9012 3456, and again [email protected].
model was sent: Hi, I'm Priya. Email <EMAIL_1> or call <PHONE_1>. Card <CARD_1>, order 1234 5678 9012 3456, and again <EMAIL_1>.
caller receives: You said: Hi, I'm Priya. Email [email protected] or call +91 98765 43210. Card 4111 1111 1111 1111, order 1234 5678 9012 3456, and again [email protected].
with restore switched off, the caller receives:
You said: Hi, I'm Priya. Email <EMAIL_1> or call <PHONE_1>. Card <CARD_1>, order 1234 5678 9012 3456, and again <EMAIL_1>.
+14
View File
@@ -0,0 +1,14 @@
# What pattern-based redaction misses
in: My name is Priya Sharma and I live at 14 Hill Road, Bandra, Mumbai 400050.
out: My name is Priya Sharma and I live at 14 Hill Road, Bandra, Mumbai 400050.
in: Passport N1234567, PAN ABCDE1234F.
out: Passport N1234567, PAN ABCDE1234F.
in: Write to priya (at) example (dot) com
out: Write to priya (at) example (dot) com
in: Card 4111-1111-1111-1112 and order 1234 5678 9012 3456
out: Card 4111-1111-1111-1112 and order 1234 5678 9012 3456
@@ -0,0 +1,11 @@
# Restoring placeholders in a stream
the model streams 5-character chunks: Sure,| I wi|ll wr|ite t|o <EM|AIL_1|> now|.
restore each chunk on its own:
chunks: Sure,| I wi|ll wr|ite t|o <EM|AIL_1|> now|.
joined: Sure, I will write to <EMAIL_1> now.
PiiRedactionAdvisor (holds back from an unfinished "<"):
chunks: Sure,| I wi|ll wr|ite t|o |[email protected] now|.
joined: Sure, I will write to [email protected] now.
+19
View File
@@ -0,0 +1,19 @@
# Redaction order versus logging and memory
memory advisor is fixed at HIGHEST_PRECEDENCE + 200
user says: My email is [email protected]
A redaction +100, logging +400 (redaction outside both):
log line saw: "My email is <EMAIL_1>"
memory stored: "My email is <EMAIL_1>"
model was sent: "My email is <EMAIL_1>"
B redaction +100, logging +50 (logging outside redaction):
log line saw: "My email is [email protected]"
memory stored: "My email is <EMAIL_1>"
model was sent: "My email is <EMAIL_1>"
C redaction +300 (inside the memory advisor), logging +400:
log line saw: "My email is <EMAIL_1>"
memory stored: "My email is [email protected]"
model was sent: "My email is <EMAIL_1>"
+18
View File
@@ -0,0 +1,18 @@
# Placeholders across two turns with memory
turn 1: My email is [email protected]
turn 2: Also cc [email protected]
numbering restarts on every request:
model was sent on turn 2: U:My email is <EMAIL_1> | A:You said: My email is <EMAIL_1> | U:Also cc <EMAIL_1>
caller receives: You said: Also cc [email protected]
numbering kept per conversation (the default):
model was sent on turn 2: U:My email is <EMAIL_1> | A:You said: My email is <EMAIL_1> | U:Also cc <EMAIL_2>
caller receives: You said: Also cc [email protected]
what the memory stores (placeholders, never the addresses):
USER My email is <EMAIL_1>
ASSISTANT You said: My email is <EMAIL_1>
USER Also cc <EMAIL_2>
ASSISTANT You said: Also cc <EMAIL_2>
+15
View File
@@ -0,0 +1,15 @@
# TokenBudgetAdvisor: 40 tokens per request, 60 per user
alice asks short questions; "spent" is the usage the model reported:
call 1: answered, spent=13, model calls=1
call 2: answered, spent=26, model calls=2
call 3: answered, spent=39, model calls=3
call 4: answered, spent=52, model calls=4
call 5: answered, spent=65, model calls=5
call 6: refused (user alice has used 65 of 60 tokens), spent=65, model calls=5
bob pastes a stack trace of 103 estimated tokens:
refused: prompt is about 103 tokens, the limit per request is 40
model calls: 5 (was 5), bob's spent: 0
bob then asks a short question: answered, bob spent=13, alice spent=65
@@ -0,0 +1,9 @@
# Token accounting on a stream
same question, same answer:
call, usage from the response: 9 tokens
stream, usage on the last chunk: 9 tokens
stream, no usage reported (estimated): 9 tokens
A stream that reports no usage is billed by the provider all the same. The estimate above
matches only because this scripted model and the advisor use the same tokenizer.
@@ -0,0 +1,5 @@
# A refusal on the stream path
subscriber got: TokenBudgetExceededException
message: prompt is about 13 tokens, the limit per request is 5
model calls: 0
+13
View File
@@ -0,0 +1,13 @@
# Where an advisor sits relative to the tool loop
one question, one tool call, so the model is called twice (ToolCallingAdvisor is at +300)
logging advisor at +250 (outside the tool loop):
[LoggingAdvisor] request messages=1 roles=U
[LoggingAdvisor] response chars=20 took 5 ms
logging advisor at +400 (inside the tool loop):
[LoggingAdvisor] request messages=1 roles=U
[LoggingAdvisor] response chars=0 took 5 ms
[LoggingAdvisor] request messages=3 roles=UAT
[LoggingAdvisor] response chars=20 took 5 ms
+11
View File
@@ -0,0 +1,11 @@
# Where a refusal surfaces
an advisor that throws IllegalStateException("refused"), model calls counted afterwards
plain advisor, call failed while running it with IllegalStateException: refused
plain advisor, stream, throws eagerly failed while running it with IllegalStateException: refused
plain advisor, stream, error inside Flux.defer failed while running it with IllegalStateException: refused
BaseAdvisor.before, call failed while running it with IllegalStateException: refused
BaseAdvisor.before, stream failed while running it with IllegalStateException: Stream processing failed (cause: IllegalStateException: refused)
model calls: 0
+7
View File
@@ -0,0 +1,7 @@
# Advisor context: request down, response up
outer: original request has tenant=null, the copy it forwards has tenant=acme
inner: request context has tenant=acme
outer: response context has verdict=clean, tenant=acme
req.context().put(...) on the incoming request: allowed
@@ -0,0 +1,6 @@
# Testing an advisor with a stub chain
what the caller sent: Mail [email protected] please
what reached the rest: Mail <EMAIL_1> please
what the caller got back: Noted: Mail [email protected] please
caller's request unchanged: true
+9
View File
@@ -0,0 +1,9 @@
# Over HTTP: 200, then 429
limits for this run: 60 tokens per request, 30 per user
dana request 1 -> HTTP 200, model calls so far: 1
dana request 2 -> HTTP 200, model calls so far: 2
dana request 3 -> HTTP 429, model calls so far: 2
dana request 4 -> HTTP 429, model calls so far: 2
erin pastes a long stack trace -> HTTP 429, model calls: 2 (was 2)
+8
View File
@@ -0,0 +1,8 @@
# Built-in advisor orders in 2.0.1
SimpleLoggerAdvisor getOrder() = 0
SafeGuardAdvisor getOrder() = 0
MessageChatMemoryAdvisor getOrder() = -2147483448 (HIGHEST_PRECEDENCE + 200)
Tool Calling Advisor getOrder() = -2147483348 (HIGHEST_PRECEDENCE + 300)
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200
+7
View File
@@ -0,0 +1,7 @@
# Token budget and the tool loop
the model reports 100+10 tokens for round 1 (asks for the tool) and 130+20 for round 2 (answers)
so the provider would bill 260 tokens for this one question
budget advisor at +250 (outside the tool loop): recorded 260
budget advisor at +400 (inside the tool loop): recorded 260