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]
