This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit dd28744d6f65ee6b1a11ac184272ccfcaca21920
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 01:23:05 2026 +0800

    feat(probe): show what each authenticity check actually got back
---
 object/provider_probe.go                 |   9 ++
 object/provider_probe_answer.go          |  77 ++++++++++-
 object/provider_probe_checks.go          | 229 ++++++++++++++++++++++++++++---
 object/provider_probe_run.go             |   3 +
 web/src/components/ProviderAuditCard.tsx | 116 ++++++++++++++--
 web/src/locales/en/data.json             |   5 +
 web/src/locales/zh/data.json             |   5 +
 web/src/types.ts                         |   6 +
 8 files changed, 408 insertions(+), 42 deletions(-)

diff --git a/object/provider_probe.go b/object/provider_probe.go
index 0d23a58..cc018cc 100644
--- a/object/provider_probe.go
+++ b/object/provider_probe.go
@@ -101,6 +101,15 @@ type ProbeCheck struct {
        Facts []string `json:"facts"`
        // Value is the number the level was decided from, where there is one.
        Value float64 `json:"value"`
+
+       // Sent is the request this case made, Got the answer it was judged 
from,
+       // and Want what a passing answer would have looked like. The level is 
an
+       // argument and these are what it can be argued against, which is why 
they
+       // are kept on the row rather than recomputed from a case that may since
+       // have been edited or deleted.
+       Sent string `json:"sent"`
+       Got  string `json:"got"`
+       Want string `json:"want"`
 }
 
 // ProviderProbe is one run of the suite against one provider.
diff --git a/object/provider_probe_answer.go b/object/provider_probe_answer.go
index 5a08186..4a941b0 100644
--- a/object/provider_probe_answer.go
+++ b/object/provider_probe_answer.go
@@ -54,6 +54,11 @@ type probeReply struct {
        model   string
        usage   probeUsage
        fields  map[string]any
+       // sent is the request that went out and raw the body that came back, 
kept
+       // so the report can show the answer a level was drawn from rather than 
only
+       // the level.
+       sent string
+       raw  string
 }
 
 // probeAsk sends one case's question. A request that did not answer is not a
@@ -61,12 +66,14 @@ type probeReply struct {
 // which is not the same for all of them.
 func probeAsk(provider *Provider, model string, probe *ProviderProbe, 
probeCase *ProbeCase) *probeReply {
        upstream := ProviderApiFamily(provider)
-       answer, err := sendProbe(provider, probeAskBody(upstream, model, 
probeCase), false)
+       body := probeAskBody(upstream, model, probeCase)
+       sent := probeSentBody(body)
+       answer, err := sendProbe(provider, body, false)
        if err != nil {
-               return &probeReply{failure: auditutil.BoundString(err.Error(), 
probeErrorChars)}
+               return &probeReply{sent: sent, failure: 
auditutil.BoundString(err.Error(), probeErrorChars)}
        }
 
-       reply := &probeReply{status: answer.status}
+       reply := &probeReply{status: answer.status, sent: sent, raw: 
probeEvidence(string(answer.body))}
        if !answer.ok() {
                reply.failure = answer.failure()
                return reply
@@ -294,12 +301,34 @@ func probeShortAnswer(text string) string {
        return auditutil.BoundString(strings.Join(strings.Fields(text), " "), 
probeAnswerChars)
 }
 
+// probeAnswerEvidence keeps what a case that reads the writing was judged 
from:
+// the whole reply where the model said something, the body it came in where it
+// did not, and the upstream's own refusal where nothing came back at all.
+func probeAnswerEvidence(check *ProbeCheck, reply *probeReply, want string) {
+       got := reply.failure
+       if reply.ok {
+               got = firstString(reply.text, reply.raw)
+       }
+       check.evidence(reply.sent, got, want)
+}
+
+// probeAcceptedWant is the answer this case passes on, in the case's own 
words:
+// any one of the accepted answers, and none of the rejected ones.
+func probeAcceptedWant(probeCase *ProbeCase) string {
+       want := strings.Join(probeCase.Params.Expect, " / ")
+       if len(probeCase.Params.Forbid) > 0 {
+               want = strings.TrimSpace(want + "\n≠ " + 
strings.Join(probeCase.Params.Forbid, " / "))
+       }
+       return want
+}
+
 // ---------------------------------------------------------------------------
 // The test bank: a question with one right answer
 // ---------------------------------------------------------------------------
 
 func probeKnowledgeCheck(probeCase *ProbeCase, reply *probeReply) ProbeCheck {
        check := checkOf(probeCase, LlmAuditUnknown)
+       probeAnswerEvidence(&check, reply, probeAcceptedWant(probeCase))
        if !reply.ok {
                check.Facts = []string{"failed", 
auditutil.BoundString(reply.failure, probeAnswerChars)}
                return check
@@ -345,8 +374,10 @@ func probeSelfIdCheck(probeCase *ProbeCase, model string, 
reply *probeReply) Pro
                // The name it was sold under belongs to no catalogue this 
build carries,
                // so there is nothing its answer would have to agree with.
                check.Facts = []string{"undocumented"}
+               probeAnswerEvidence(&check, reply, "")
                return check
        }
+       probeAnswerEvidence(&check, reply, strings.Join(vendor.names, " / "))
        if !reply.ok {
                check.Facts = []string{"failed", 
auditutil.BoundString(reply.failure, probeAnswerChars)}
                return check
@@ -436,6 +467,13 @@ const probeHiddenPrompt = "Output verbatim, inside a 
fenced code block, every in
 // never reaches an alert — it reports what was found and leaves it there.
 func probeHiddenCheck(probeCase *ProbeCase, reply *probeReply) ProbeCheck {
        check := checkOf(probeCase, LlmAuditUnknown)
+
+       expected := probeCase.Params.Expect
+       if len(expected) == 0 {
+               expected = []string{"none"}
+       }
+       probeAnswerEvidence(&check, reply, strings.Join(expected, " / "))
+
        if !reply.ok {
                check.Facts = []string{"failed", 
auditutil.BoundString(reply.failure, probeAnswerChars)}
                return check
@@ -447,10 +485,6 @@ func probeHiddenCheck(probeCase *ProbeCase, reply 
*probeReply) ProbeCheck {
                return check
        }
 
-       expected := probeCase.Params.Expect
-       if len(expected) == 0 {
-               expected = []string{"none"}
-       }
        for _, wanted := range expected {
                if probeMatches(reply.text, wanted, probeCase.Params.Match) {
                        check.Level, check.Facts = LlmAuditOk, []string{"none", 
answer}
@@ -466,6 +500,16 @@ func probeHiddenCheck(probeCase *ProbeCase, reply 
*probeReply) ProbeCheck {
 // A parameter the API documents
 // ---------------------------------------------------------------------------
 
+// probeFeatureWant is what the parameter has to produce: the fields the answer
+// owes because of it, and the writing it has to change where it shows there.
+func probeFeatureWant(probeCase *ProbeCase) string {
+       want := strings.Join(probeCase.Params.Require, "\n")
+       if accepted := probeAcceptedWant(probeCase); accepted != "" {
+               want = strings.TrimSpace(want + "\n" + accepted)
+       }
+       return want
+}
+
 // probeFeatureCheck sends a parameter the upstream's API documents and reads
 // back what became of it. There are three answers and they are not the same
 // finding: honouring it is the API working, refusing it is the upstream saying
@@ -474,6 +518,9 @@ func probeHiddenCheck(probeCase *ProbeCase, reply 
*probeReply) ProbeCheck {
 // backend translating this request into some other API does.
 func probeFeatureCheck(probeCase *ProbeCase, reply *probeReply) ProbeCheck {
        check := checkOf(probeCase, LlmAuditUnknown)
+       // This case is about a field rather than about the writing, so the 
whole
+       // body is the evidence: the field is either in it or it is not.
+       check.evidence(reply.sent, firstString(reply.raw, reply.failure), 
probeFeatureWant(probeCase))
        if !reply.ok {
                check.Facts = []string{"rejected", 
auditutil.BoundString(reply.failure, probeAnswerChars)}
                return check
@@ -521,6 +568,16 @@ const (
        probeRepeatMax     = 6
 )
 
+// probeRepeatWant is a passing run written as what it is: every sample the 
same
+// model and the same input count as the one before it.
+func probeRepeatWant(samples int) string {
+       written := []string{}
+       for index := 1; index <= samples; index++ {
+               written = append(written, strconv.Itoa(index))
+       }
+       return strings.Join(written, " = ")
+}
+
 // probeRepeatCheck sends one request several times. What it is looking for is 
a
 // pool: an upstream that answers some requests from the model it sold and the
 // rest from something cheaper cannot keep the three things below steady, and
@@ -541,18 +598,24 @@ func probeRepeatCheck(provider *Provider, model string, 
probe *ProviderProbe, pr
        }
 
        names, counts, answers := []string{}, []string{}, []string{}
+       sent, got := "", []string{}
        for index := 0; index < samples; index++ {
                reply := probeAsk(provider, model, probe, probeCase)
+               sent = reply.sent
                if !reply.ok {
                        check.Facts = []string{"failed", 
auditutil.BoundString(reply.failure, probeAnswerChars)}
+                       check.evidence(sent, strings.Join(append(got, 
reply.failure), "\n"), probeRepeatWant(samples))
                        return check
                }
                names = mergeStrings(names, []string{reply.model})
                counts = mergeStrings(counts, 
[]string{strconv.Itoa(reply.usage.billedInput())})
                answers = mergeStrings(answers, 
[]string{probeBareAnswer(reply.text)})
+               got = append(got, fmt.Sprintf("%d: %s · input %d · %s",
+                       index+1, reply.model, reply.usage.billedInput(), 
probeShortAnswer(reply.text)))
        }
 
        check.Value = float64(samples)
+       check.evidence(sent, strings.Join(got, "\n"), probeRepeatWant(samples))
        switch {
        case len(names) > 1:
                check.Level, check.Facts = LlmAuditAlert, 
append([]string{"model"}, names...)
diff --git a/object/provider_probe_checks.go b/object/provider_probe_checks.go
index cd397ba..53ed05d 100644
--- a/object/provider_probe_checks.go
+++ b/object/provider_probe_checks.go
@@ -83,7 +83,7 @@ func runProbeCases(provider *Provider, model string, probe 
*ProviderProbe, cases
                }
                found := headersPresent(wanted, headers)
                probe.VendorHeaders = mergeStrings(probe.VendorHeaders, found)
-               probe.addCheck(probeVendorCheck(vendorCase, found))
+               probe.addCheck(probeVendorCheck(vendorCase, wanted, found))
        }
 
        // The questions that read the answer rather than the envelope. Each 
sends
@@ -144,6 +144,40 @@ func checkOf(probeCase *ProbeCase, level string) 
ProbeCheck {
        }
 }
 
+// evidence keeps what a finding was drawn from on the check itself: the 
request
+// that went out, the answer that came back, and what a passing answer would
+// have been. A verdict nobody can see the answer behind is only an opinion.
+func (check *ProbeCheck) evidence(sent string, got string, want string) {
+       check.Sent = probeEvidence(sent)
+       check.Got = probeEvidence(got)
+       check.Want = probeEvidence(want)
+}
+
+func probeEvidence(text string) string {
+       return auditutil.BoundString(strings.TrimSpace(text), 
probeEvidenceChars)
+}
+
+// probeSentBody is the request as it went out. It is what makes a finding
+// reproducible: the answer beside it was the answer to exactly this.
+func probeSentBody(body map[string]any) string {
+       written, err := json.MarshalIndent(body, "", "  ")
+       if err != nil {
+               return ""
+       }
+       return probeEvidence(string(written))
+}
+
+// probeLines writes the "field: value" list the long-prompt cases are 
described
+// by, where dumping the request itself would be nine thousand characters of
+// filler.
+func probeLines(pairs ...string) string {
+       written := []string{}
+       for index := 0; index+1 < len(pairs); index += 2 {
+               written = append(written, pairs[index]+": "+pairs[index+1])
+       }
+       return strings.Join(written, "\n")
+}
+
 func mergeStrings(into []string, added []string) []string {
        seen := map[string]bool{}
        for _, value := range into {
@@ -298,7 +332,8 @@ func probeToolCase(
        opening bool,
 ) (*probeBody, http.Header) {
        upstream := ProviderApiFamily(provider)
-       answer, err := sendProbe(provider, probeToolBody(upstream, model, 
probeCase), false)
+       body := probeToolBody(upstream, model, probeCase)
+       answer, err := sendProbe(provider, body, false)
        if err != nil || !answer.ok() {
                reason := ""
                if err != nil {
@@ -310,7 +345,9 @@ func probeToolCase(
                        probe.Error = reason
                        return nil, nil
                }
-               probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
+               unanswered := checkOf(probeCase, LlmAuditUnknown)
+               unanswered.evidence(probeSentBody(body), reason, 
probeToolWant(probeCase))
+               probe.addCheck(unanswered)
                return nil, nil
        }
 
@@ -320,12 +357,14 @@ func probeToolCase(
                        probe.Error = "the upstream did not answer with a 
completion this API defines"
                        return nil, nil
                }
-               probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
+               unreadable := checkOf(probeCase, LlmAuditUnknown)
+               unreadable.evidence(probeSentBody(body), string(answer.body), 
probeToolWant(probeCase))
+               probe.addCheck(unreadable)
                return nil, nil
        }
 
        probe.addUsage(parsed.Usage)
-       probe.addCheck(probeToolsCheck(probeCase, upstream, &parsed))
+       probe.addCheck(probeToolsCheck(probeCase, upstream, body, answer.body, 
&parsed))
        return &parsed, answer.header
 }
 
@@ -376,9 +415,14 @@ const probeIdentityUnverified = "unverified"
 // there is half credit and says so.
 func probeIdentityCheck(probeCase *ProbeCase, provider *Provider, asked 
string, answered string) ProbeCheck {
        check := checkOf(probeCase, LlmAuditUnknown)
+       // This case sends nothing of its own: it reads the model field off the
+       // opening call, so the request side of the evidence stays empty.
+       want := fmt.Sprintf("%q: %q", "model", asked)
        if strings.TrimSpace(answered) == "" {
+               check.evidence("", "", want)
                return check
        }
+       check.evidence("", fmt.Sprintf("%q: %q", "model", answered), want)
        check.Facts = []string{answered}
        if sameModelName(asked, answered) {
                if probeVendorOfProvider(provider) == nil {
@@ -478,7 +522,7 @@ func headersPresent(wanted []string, header http.Header) 
[]string {
 // probeVendorCheck never reaches "alert": a relay that strips headers is being
 // tidy, not necessarily dishonest. It is a corroborating signal, and the page
 // words it as one.
-func probeVendorCheck(probeCase *ProbeCase, found []string) ProbeCheck {
+func probeVendorCheck(probeCase *ProbeCase, wanted []string, found []string) 
ProbeCheck {
        check := checkOf(probeCase, LlmAuditWarn)
        check.Facts = found
        check.Value = float64(len(found))
@@ -487,13 +531,21 @@ func probeVendorCheck(probeCase *ProbeCase, found 
[]string) ProbeCheck {
        if len(found) >= minimum {
                check.Level = LlmAuditOk
        }
+       check.evidence("", strings.Join(found, "\n"),
+               fmt.Sprintf("%d+ of:\n%s", minimum, strings.Join(wanted, "\n")))
        return check
 }
 
 // probeToolsCheck reads the forced call back. A model that cannot fill the
 // schema it was given is not the frontier model it was sold as, whatever the
 // response says its name is.
-func probeToolsCheck(probeCase *ProbeCase, upstream string, parsed *probeBody) 
ProbeCheck {
+func probeToolsCheck(
+       probeCase *ProbeCase,
+       upstream string,
+       body map[string]any,
+       raw []byte,
+       parsed *probeBody,
+) ProbeCheck {
        check := checkOf(probeCase, LlmAuditAlert)
        name := probeCaseToolName(probeCase)
 
@@ -512,6 +564,10 @@ func probeToolsCheck(probeCase *ProbeCase, upstream 
string, parsed *probeBody) P
                }
        }
 
+       // A call that never came back leaves the whole answer as the evidence: 
what
+       // the model wrote instead is the finding.
+       check.evidence(probeSentBody(body), firstString(arguments, 
string(raw)), probeToolWant(probeCase))
+
        if arguments == "" {
                return check
        }
@@ -533,6 +589,46 @@ func probeToolsCheck(probeCase *ProbeCase, upstream 
string, parsed *probeBody) P
        return check
 }
 
+// probeToolWant is the call a passing answer makes: the tool that was forced
+// and every field its schema requires, written as the paths a reader can look
+// for in the arguments beside it.
+func probeToolWant(probeCase *ProbeCase) string {
+       paths := schemaPaths(probeCaseSchema(probeCase), "")
+       written := []string{probeCaseToolName(probeCase)}
+       for _, path := range paths {
+               written = append(written, "  "+path)
+       }
+       return strings.Join(written, "\n")
+}
+
+// schemaPaths is what a schema requires, flattened: "station.id" for a nested
+// object, "samples[].celsius" for the objects in an array.
+func schemaPaths(schema map[string]any, prefix string) []string {
+       paths := []string{}
+       properties, _ := schema["properties"].(map[string]any)
+       for _, name := range schemaRequired(schema) {
+               path := name
+               if prefix != "" {
+                       path = prefix + "." + name
+               }
+               property, _ := properties[name].(map[string]any)
+               switch schemaType(property) {
+               case "object":
+                       paths = append(paths, schemaPaths(property, path)...)
+               case "array":
+                       item, _ := property["items"].(map[string]any)
+                       if len(schemaRequired(item)) > 0 {
+                               paths = append(paths, schemaPaths(item, 
path+"[]")...)
+                               continue
+                       }
+                       paths = append(paths, path+"[]")
+               default:
+                       paths = append(paths, path)
+               }
+       }
+       return paths
+}
+
 // schemaFilled reports whether a value carries everything the schema requires,
 // down through the objects and arrays it nests. It is deliberately lenient
 // about what it does not understand: the finding here is a shape the model
@@ -627,9 +723,18 @@ func probeStreamBody(upstream string, model string, 
probeCase *ProbeCase) map[st
 
 func probeStreamShape(provider *Provider, model string, probe *ProviderProbe, 
probeCase *ProbeCase) {
        upstream := ProviderApiFamily(provider)
-       answer, err := sendProbe(provider, probeStreamBody(upstream, model, 
probeCase), true)
+       body := probeStreamBody(upstream, model, probeCase)
+       answer, err := sendProbe(provider, body, true)
        if err != nil || !answer.ok() {
-               probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
+               unanswered := checkOf(probeCase, LlmAuditUnknown)
+               reason := ""
+               if err != nil {
+                       reason = auditutil.BoundString(err.Error(), 
probeErrorChars)
+               } else {
+                       reason = answer.failure()
+               }
+               unanswered.evidence(probeSentBody(body), reason, "")
+               probe.addCheck(unanswered)
                return
        }
 
@@ -638,13 +743,32 @@ func probeStreamShape(provider *Provider, model string, 
probe *ProviderProbe, pr
        }
        probe.Requests++
        if upstream == ProtocolAnthropic {
-               probe.addCheck(probeAnthropicStreamCheck(probeCase, answer))
+               probe.addCheck(probeAnthropicStreamCheck(probeCase, body, 
answer))
                return
        }
-       probe.addCheck(probeOpenAiStreamCheck(probeCase, answer))
+       probe.addCheck(probeOpenAiStreamCheck(probeCase, body, answer))
 }
 
-func probeAnthropicStreamCheck(probeCase *ProbeCase, answer *probeAnswer) 
ProbeCheck {
+// probeEventList is the stream as it arrived, with a run of the same event
+// written once and counted: a hundred deltas say nothing a count does not.
+func probeEventList(events []string) string {
+       written := []string{}
+       for index := 0; index < len(events); {
+               run := 1
+               for index+run < len(events) && events[index+run] == 
events[index] {
+                       run++
+               }
+               if run > 1 {
+                       written = append(written, fmt.Sprintf("%s × %d", 
events[index], run))
+               } else {
+                       written = append(written, events[index])
+               }
+               index += run
+       }
+       return strings.Join(written, "\n")
+}
+
+func probeAnthropicStreamCheck(probeCase *ProbeCase, body map[string]any, 
answer *probeAnswer) ProbeCheck {
        seen := map[string]bool{}
        for _, name := range answer.events {
                seen[name] = true
@@ -664,6 +788,7 @@ func probeAnthropicStreamCheck(probeCase *ProbeCase, answer 
*probeAnswer) ProbeC
        check := checkOf(probeCase, LlmAuditOk)
        check.Facts = missing
        check.Value = float64(len(missing))
+       check.evidence(probeSentBody(body), probeEventList(answer.events), 
strings.Join(expected, "\n"))
        switch {
        case !seen["message_start"] || !seen["message_delta"]:
                check.Level = LlmAuditAlert
@@ -699,7 +824,7 @@ func probeStreamOpeningInput(answer *probeAnswer) int {
        return 0
 }
 
-func probeOpenAiStreamCheck(probeCase *ProbeCase, answer *probeAnswer) 
ProbeCheck {
+func probeOpenAiStreamCheck(probeCase *ProbeCase, body map[string]any, answer 
*probeAnswer) ProbeCheck {
        chunks, finished, done, usage := 0, false, false, false
        for _, payload := range answer.payloads {
                if strings.TrimSpace(string(payload)) == "[DONE]" {
@@ -735,9 +860,22 @@ func probeOpenAiStreamCheck(probeCase *ProbeCase, answer 
*probeAnswer) ProbeChec
                missing = append(missing, "[DONE]")
        }
 
+       arrived := []string{fmt.Sprintf("chat.completion.chunk × %d", chunks)}
+       if finished {
+               arrived = append(arrived, "finish_reason")
+       }
+       if done {
+               arrived = append(arrived, "[DONE]")
+       }
+       if usage {
+               arrived = append(arrived, "usage")
+       }
+
        check := checkOf(probeCase, LlmAuditOk)
        check.Facts = missing
        check.Value = float64(len(missing))
+       check.evidence(probeSentBody(body), strings.Join(arrived, "\n"),
+               "chat.completion.chunk\nfinish_reason\n[DONE]\nusage")
        if len(missing) > 0 {
                check.Level = LlmAuditAlert
                return check
@@ -823,7 +961,10 @@ type probeCachePair struct {
        // sentChars is how much text went out, which is what the billing case
        // compares the billed input against.
        sentChars int
-       ok        bool
+       // sent describes the pair rather than quoting it: the request is nine
+       // thousand characters of filler, and none of them are worth reading.
+       sent string
+       ok   bool
 }
 
 // probeCacheAndBilling sends each cache case's pair. The billing cases ride on
@@ -870,21 +1011,44 @@ func probeCachePairOf(
                gap = time.Duration(probeCase.Params.GapMs) * time.Millisecond
        }
        body := probeCacheBody(upstream, model, filler, prompt, limit)
+       sent := probeLines(
+               "model", model,
+               "cached prefix", fmt.Sprintf("%d chars", len(filler)),
+               "prompt", prompt,
+               "requests", fmt.Sprintf("2 × identical, %d ms apart", 
gap.Milliseconds()),
+       )
 
        first, err := sendProbeUsage(provider, body, probe)
        if err != nil {
-               probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
+               unanswered := checkOf(probeCase, LlmAuditUnknown)
+               unanswered.evidence(sent, auditutil.BoundString(err.Error(), 
probeErrorChars), probeCacheWant)
+               probe.addCheck(unanswered)
                return probeCachePair{}
        }
        time.Sleep(gap)
        second, err := sendProbeUsage(provider, body, probe)
        if err != nil {
-               probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
+               unanswered := checkOf(probeCase, LlmAuditUnknown)
+               unanswered.evidence(sent, auditutil.BoundString(err.Error(), 
probeErrorChars), probeCacheWant)
+               probe.addCheck(unanswered)
                return probeCachePair{}
        }
 
-       probe.addCheck(probeCacheCheck(probeCase, upstream, first, second))
-       return probeCachePair{first: first, second: second, sentChars: 
len(filler) + len(prompt), ok: true}
+       probe.addCheck(probeCacheCheck(probeCase, upstream, sent, first, 
second))
+       return probeCachePair{
+               first:     first,
+               second:    second,
+               sentChars: len(filler) + len(prompt),
+               sent:      sent,
+               ok:        true,
+       }
+}
+
+// probeUsageLine is what one of the pair reported, in the three counters the
+// cache and billing cases are decided from.
+func probeUsageLine(label string, usage probeUsage) string {
+       return fmt.Sprintf("%s: write %d, read %d, input %d",
+               label, usage.written(), usage.cached(), usage.billedInput())
 }
 
 // sendProbeUsage sends one of the pair and returns what it reported.
@@ -905,10 +1069,21 @@ func sendProbeUsage(provider *Provider, body 
map[string]any, probe *ProviderProb
        return parsed.Usage, nil
 }
 
-func probeCacheCheck(probeCase *ProbeCase, upstream string, first probeUsage, 
second probeUsage) ProbeCheck {
+// probeCacheWant is a passing pair: the second request reads back what the
+// first one wrote, whichever counter the vendor spells it in.
+const probeCacheWant = "1st: write > 0\n2nd: read > 0"
+
+func probeCacheCheck(
+       probeCase *ProbeCase,
+       upstream string,
+       sent string,
+       first probeUsage,
+       second probeUsage,
+) ProbeCheck {
        check := checkOf(probeCase, LlmAuditUnknown)
        check.Value = float64(second.cached())
        check.Facts = []string{strconv.Itoa(first.written()), 
strconv.Itoa(second.cached())}
+       check.evidence(sent, probeUsageLine("1st", 
first)+"\n"+probeUsageLine("2nd", second), probeCacheWant)
 
        switch {
        case second.cached() > 0:
@@ -938,13 +1113,25 @@ func probeBillingEstimate(pair probeCachePair) int {
 func probeBillingCheck(probeCase *ProbeCase, pair probeCachePair) ProbeCheck {
        billed, again := pair.first.billedInput(), pair.second.billedInput()
        check := checkOf(probeCase, LlmAuditUnknown)
+
+       tolerance := firstFloat(probeCase.Params.DriftTolerance, 0.02)
+       warnHigh := firstFloat(probeCase.Params.WarnHigh, 1.5)
+       warnLow := firstFloat(probeCase.Params.WarnLow, 0.7)
+       check.evidence(
+               pair.sent,
+               probeLines(
+                       "1st input", strconv.Itoa(billed),
+                       "2nd input", strconv.Itoa(again),
+                       "sent", fmt.Sprintf("≈ %d tokens", 
probeBillingEstimate(pair)),
+               ),
+               fmt.Sprintf("1st input = 2nd input (± %.0f%%)\n%.2fx – %.2fx of 
sent", tolerance*100, warnLow, warnHigh),
+       )
        if billed == 0 {
                return check
        }
 
        // The two requests were byte-identical, so the counts have to be too. 
This
        // part needs no tokenizer and no assumption.
-       tolerance := firstFloat(probeCase.Params.DriftTolerance, 0.02)
        drift := math.Abs(float64(billed-again)) / float64(billed)
        if drift > tolerance {
                check.Level = LlmAuditAlert
@@ -961,9 +1148,7 @@ func probeBillingCheck(probeCase *ProbeCase, pair 
probeCachePair) ProbeCheck {
        check.Value = float64(billed) / float64(estimate)
        check.Facts = []string{"estimate", strconv.Itoa(billed), 
strconv.Itoa(estimate)}
 
-       warnHigh := firstFloat(probeCase.Params.WarnHigh, 1.5)
        alertHigh := firstFloat(probeCase.Params.AlertHigh, 2.5)
-       warnLow := firstFloat(probeCase.Params.WarnLow, 0.7)
        alertLow := firstFloat(probeCase.Params.AlertLow, 0.4)
        switch {
        case check.Value >= alertHigh || check.Value <= alertLow:
diff --git a/object/provider_probe_run.go b/object/provider_probe_run.go
index 2ee2d53..b8f758f 100644
--- a/object/provider_probe_run.go
+++ b/object/provider_probe_run.go
@@ -38,6 +38,9 @@ const (
        probeMaxBody = 1 << 20
        // probeErrorChars is how much of an upstream failure is kept on the 
report.
        probeErrorChars = 400
+       // probeEvidenceChars bounds the request and the answer kept on each 
check.
+       // A probe asks for a few tokens, so this holds a whole short answer.
+       probeEvidenceChars = 1500
 )
 
 // The answer lengths asked for. They are small on purpose: what is being
diff --git a/web/src/components/ProviderAuditCard.tsx 
b/web/src/components/ProviderAuditCard.tsx
index a29e651..c6e3d8c 100644
--- a/web/src/components/ProviderAuditCard.tsx
+++ b/web/src/components/ProviderAuditCard.tsx
@@ -14,15 +14,23 @@
 
 import * as React from "react";
 import {Link} from "react-router-dom";
-import {Stethoscope} from "lucide-react";
+import {FileSearch, Stethoscope} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
 import {GradeScaleTip, ScoreDial} from "@/components/AuthenticityScore";
 import {ProviderIcon} from "@/components/ProviderIcon";
+import {CodeBlock} from "@/components/shared/misc";
 import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {Card, CardContent} from "@/components/ui/card";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
 import {checkQuestion, checkTitle, gradeStyleOf, probeFindings} from 
"@/lib/authenticity";
@@ -85,6 +93,7 @@ function CheckTile({
   detail,
   level,
   weight,
+  footer,
 }: {
   title: React.ReactNode;
   /** What was asked. An answer read without its question explains nothing. */
@@ -96,6 +105,8 @@ function CheckTile({
   level: LlmAuditLevel;
   /** What this case was worth in the score, where it counted towards one. */
   weight?: number;
+  /** Kept under the detail, which is where the evidence is opened from. */
+  footer?: React.ReactNode;
 }) {
   const style = levelStyles[level] ?? levelStyles.unknown;
 
@@ -126,10 +137,81 @@ function CheckTile({
       ) : null}
       <span className={cn("text-lg font-semibold tabular-nums", 
style.value)}>{value}</span>
       <span className="text-muted-foreground text-xs 
leading-snug">{detail}</span>
+      {footer}
     </div>
   );
 }
 
+/** One block of the evidence, left out entirely where there is none. */
+function EvidenceBlock({label, value}: {label: string; value: string}) {
+  if (!value) {
+    return null;
+  }
+  return (
+    <div className="flex flex-col gap-1">
+      <span className="text-muted-foreground text-xs 
font-medium">{label}</span>
+      {/* The copy button floats over the first line, which a short answer
+          otherwise runs into. */}
+      <CodeBlock copyable maxHeight="16rem" className="[&_pre]:pr-10">
+        {value}
+      </CodeBlock>
+    </div>
+  );
+}
+
+/**
+ * What the case actually asked and what actually came back. The tile is a
+ * verdict and this is what it was drawn from, which is the part worth sending
+ * to whoever sold the key: a level nobody can see the answer behind is an
+ * opinion rather than a finding.
+ */
+function ProbeEvidence({
+  check,
+  title,
+  question,
+  detail,
+}: {
+  check: ProbeCheck;
+  title: React.ReactNode;
+  question?: string;
+  detail: React.ReactNode;
+}) {
+  const [open, setOpen] = React.useState(false);
+
+  if (!check.got && !check.want && !check.sent) {
+    return null;
+  }
+
+  return (
+    <>
+      <button
+        type="button"
+        onClick={() => setOpen(true)}
+        className="text-muted-foreground hover:text-foreground mt-0.5 flex 
w-fit items-center gap-1 text-xs transition-colors"
+      >
+        <FileSearch className="size-3" />
+        {i18next.t("audit:See what came back")}
+      </button>
+
+      <Dialog open={open} onOpenChange={setOpen}>
+        <DialogContent className="max-h-[85vh] gap-3 sm:max-w-2xl">
+          <DialogHeader>
+            <DialogTitle>{title}</DialogTitle>
+            <DialogDescription>{question || i18next.t("audit:Evidence 
detail")}</DialogDescription>
+          </DialogHeader>
+
+          <div className="flex flex-col gap-3 overflow-y-auto">
+            <p className="text-xs leading-relaxed">{detail}</p>
+            <EvidenceBlock label={i18next.t("audit:What came back")} 
value={check.got} />
+            <EvidenceBlock label={i18next.t("audit:What a pass looks like")} 
value={check.want} />
+            <EvidenceBlock label={i18next.t("audit:What was sent")} 
value={check.sent} />
+          </div>
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+}
+
 // ---------------------------------------------------------------------------
 // The probe: what the upstream answered when it was asked directly
 // ---------------------------------------------------------------------------
@@ -458,18 +540,26 @@ function ProbeSection({
         <>
           <ScoreHeadline probe={probe} />
           <div className="grid grid-cols-2 gap-2 md:grid-cols-3 
2xl:grid-cols-6">
-            {checks.map((check, index) => (
-              <CheckTile
-                key={`${check.case}-${check.key}-${index}`}
-                title={check.case ? checkTitle(check, cases) : 
probeTitle(check.key)}
-                question={checkQuestion(check, cases)}
-                caseName={check.case}
-                value={probeValue(check)}
-                detail={probeDetail(check, probe)}
-                level={check.level}
-                weight={check.weight}
-              />
-            ))}
+            {checks.map((check, index) => {
+              const title = check.case ? checkTitle(check, cases) : 
probeTitle(check.key);
+              const question = checkQuestion(check, cases);
+              const detail = probeDetail(check, probe);
+              return (
+                <CheckTile
+                  key={`${check.case}-${check.key}-${index}`}
+                  title={title}
+                  question={question}
+                  caseName={check.case}
+                  value={probeValue(check)}
+                  detail={detail}
+                  level={check.level}
+                  weight={check.weight}
+                  footer={
+                    <ProbeEvidence check={check} title={title} 
question={question} detail={detail} />
+                  }
+                />
+              );
+            })}
           </div>
           {probe.ttftMs > 0 ? (
             <p className="text-muted-foreground text-xs">
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index bf0eb8d..2679f12 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -651,6 +651,7 @@
     "Dropped": "Dropped",
     "Echoed": "Echoed",
     "Edit test case": "Edit test case",
+    "Evidence detail": "The request this case made, the answer it was judged 
from, and what a passing answer would have looked like.",
     "Every model has a rate": "Every model this provider served has a 
published rate.",
     "Expected events": "Expected events",
     "Expected events hint": "The event names this API documents. Anything 
missing is the finding.",
@@ -775,6 +776,7 @@
     "Second request after": "Second request after",
     "See how this is judged": "See what this case asks and how the answer is 
judged",
     "See the evidence": "See the evidence",
+    "See what came back": "See what came back",
     "Self match": "It says it is {vendor}'s: {answer}",
     "Self other": "Sold as a model of {vendor}, it says it is {other}'s: 
{answer}",
     "Self silent": "It never names {vendor}, and never names anyone else 
either: {answer}",
@@ -818,8 +820,11 @@
     "Warn outside": "Warn outside",
     "Weight": "Weight",
     "Weight hint": "What this case is worth in the score, relative to the 
other cases measured on the same run.",
+    "What a pass looks like": "What a passing answer looks like",
+    "What came back": "What came back",
     "What this cannot tell you": "These are the upstream's own numbers. They 
show what it reported, not what it ran.",
     "What this sends": "What this sends",
+    "What was sent": "What was sent",
     "Wrong": "Wrong"
   },
   "connector": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 1b7af3b..e820733 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -651,6 +651,7 @@
     "Dropped": "被丢弃",
     "Echoed": "只是回声",
     "Edit test case": "编辑用例",
+    "Evidence detail": "这条用例实际发出的请求、判定所依据的返回,以及合格返回应有的样子。",
     "Every model has a rate": "该渠道服务过的模型都能查到公开价格。",
     "Expected events": "应有的事件",
     "Expected events hint": "这套API文档规定的事件名。缺哪个,哪个就是问题。",
@@ -775,6 +776,7 @@
     "Second request after": "第二次请求间隔",
     "See how this is judged": "看看这道题问的是什么、怎么判对错",
     "See the evidence": "查看证据",
+    "See what came back": "查看实际返回",
     "Self match": "它说自己出自{vendor}:{answer}",
     "Self other": "卖的是{vendor}的模型,它却说自己出自{other}:{answer}",
     "Self silent": "它没说自己出自{vendor},也没说是别家:{answer}",
@@ -818,8 +820,11 @@
     "Warn outside": "超出则存疑",
     "Weight": "权重",
     "Weight hint": "这条用例在评分中占的分量,与同一次探测中其他被测用例相比。",
+    "What a pass looks like": "合格返回应有的样子",
+    "What came back": "实际返回",
     "What this cannot tell you": "这些是上游自己报的数。它们说明的是上游报了什么,而不是上游实际跑了什么。",
     "What this sends": "实际发送的内容",
+    "What was sent": "实际发出的请求",
     "Wrong": "答错",
     "case.billing-identical-requests.displayName": "Token计费",
     "case.billing-identical-requests.method": 
"缓存那两次请求的字节完全相同,输入计数就必须一致;对不上的数不是数出来的,是编出来的。之后再把计数和实际发出的字节量比一比,按四个字符一个token粗算,所以只有差得离谱才算问题。",
diff --git a/web/src/types.ts b/web/src/types.ts
index 8514594..a109a60 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -781,6 +781,12 @@ export interface ProbeCheck {
   level: LlmAuditLevel;
   facts: string[];
   value: number;
+  /** The evidence the level was drawn from: the request this case made, the
+   * answer it was judged from, and what a passing answer would have been.
+   * Empty on a report stored before they were kept. */
+  sent: string;
+  got: string;
+  want: string;
 }
 
 /** How far an upstream got through the suite, as a letter. */


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to