You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/03 09:22:49 UTC
(casbin-gateway) branch master updated: fix(authenticity): stop penalizing documented model aliases and foreign vendor headers
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
The following commit(s) were added to refs/heads/master by this push:
new 57fd507 fix(authenticity): stop penalizing documented model aliases and foreign vendor headers
57fd507 is described below
commit 57fd5078f98706b433cb2d7545e7c01e2c9c771c
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Thu Sep 3 17:22:39 2026 +0800
fix(authenticity): stop penalizing documented model aliases and foreign vendor headers
---
object/probe_case.go | 113 +++++++++++-----
object/probe_case_builtin.go | 45 ++-----
object/provider_probe_checks.go | 75 +++++++----
object/provider_probe_score.go | 2 +-
object/provider_probe_vendor.go | 221 +++++++++++++++++++++++++++++++
web/src/components/ProviderAuditCard.tsx | 9 +-
web/src/locales/en/data.json | 2 +
web/src/locales/zh/data.json | 13 +-
8 files changed, 381 insertions(+), 99 deletions(-)
diff --git a/object/probe_case.go b/object/probe_case.go
index fc2b569..ac79ae5 100644
--- a/object/probe_case.go
+++ b/object/probe_case.go
@@ -19,6 +19,8 @@
package object
import (
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -87,6 +89,11 @@ type ProbeCase struct {
// BuiltIn marks a case Gateway ships, which is what restoring the defaults
// puts back. An edited built-in case stays built-in.
BuiltIn bool `xorm:"bool" json:"builtIn"`
+ // Shipped fingerprints the built-in case this row was written from. It is
+ // what tells a case nobody has touched from one someone rewrote, which is
+ // what lets a later release improve a shipped case without overwriting
+ // anyone's words.
+ Shipped string `xorm:"varchar(64)" json:"-"`
Question string `xorm:"varchar(1000)" json:"question"`
Method string `xorm:"varchar(2000)" json:"method"`
@@ -127,36 +134,44 @@ func GetProbeCases() ([]*ProbeCase, error) {
return cases, nil
}
-// markEditedProbeCases compares each built-in case against the one Gateway
-// ships. Only the words are compared: a case that was reweighted or turned off
-// is still the shipped question, asked the shipped way.
+// markEditedProbeCases compares each built-in case against the words it was
+// written with. Only the question is compared: a case that was reweighted or
+// turned off is still the shipped question, asked the shipped way.
func markEditedProbeCases(cases []*ProbeCase) {
- shipped := map[string]*ProbeCase{}
- for _, probeCase := range builtInProbeCases() {
- shipped[probeCase.Name] = probeCase
- }
-
for _, probeCase := range cases {
- original, ok := shipped[probeCase.Name]
- if !probeCase.BuiltIn || !ok {
- continue
- }
- probeCase.Edited = probeCase.DisplayName != original.DisplayName ||
- probeCase.Question != original.Question ||
- probeCase.Method != original.Method ||
- !sameProbeParams(probeCase.Params, original.Params)
+ probeCase.Edited = isProbeCaseEdited(probeCase)
}
}
-// sameProbeParams compares two parameter sets the way a reader would: what is
-// sent and what it is compared against, slices included.
-func sameProbeParams(left ProbeCaseParams, right ProbeCaseParams) bool {
- leftJson, leftErr := json.Marshal(left)
- rightJson, rightErr := json.Marshal(right)
- if leftErr != nil || rightErr != nil {
+func isProbeCaseEdited(probeCase *ProbeCase) bool {
+ if !probeCase.BuiltIn {
return false
}
- return string(leftJson) == string(rightJson)
+ // A row stored before cases were fingerprinted has only its timestamps to
+ // go on, which are enough: a seeded case is written once, so one that was
+ // never written again is one nobody has touched.
+ if probeCase.Shipped == "" {
+ return probeCase.UpdatedTime != probeCase.CreatedTime
+ }
+ return probeCaseFingerprint(probeCase) != probeCase.Shipped
+}
+
+// probeCaseFingerprint covers what a case asks and how it judges the answer,
+// which is what a reader would call the case itself.
+func probeCaseFingerprint(probeCase *ProbeCase) string {
+ params, err := json.Marshal(probeCase.Params)
+ if err != nil {
+ params = []byte("{}")
+ }
+ sum := sha256.Sum256([]byte(strings.Join([]string{
+ probeCase.DisplayName,
+ probeCase.Check,
+ probeCase.Protocol,
+ probeCase.Question,
+ probeCase.Method,
+ string(params),
+ }, "|")))
+ return hex.EncodeToString(sum[:])
}
func sortProbeCases(cases []*ProbeCase) {
@@ -255,6 +270,7 @@ func UpdateProbeCase(name string, probeCase *ProbeCase) error {
probeCase.Name = name
probeCase.BuiltIn = stored.BuiltIn
+ probeCase.Shipped = stored.Shipped
probeCase.CreatedTime = stored.CreatedTime
probeCase.UpdatedTime = util.GetCurrentTime()
_, err = ormer.Engine.ID(name).AllCols().Update(probeCase)
@@ -310,9 +326,10 @@ func normalizeProbeCase(probeCase *ProbeCase) error {
return nil
}
-// InitProbeCases writes the shipped suite on first start. It only ever adds the
-// cases missing by name, so an edited case survives a restart and a case added
-// in a later release still arrives.
+// InitProbeCases brings the stored suite up to what this release ships: cases
+// that are missing are added, a shipped case nobody has rewritten is rewritten
+// as it now ships, and one this release no longer ships is dropped. A case
+// someone wrote or edited is left exactly as they left it.
func InitProbeCases() {
if err := seedProbeCases(); err != nil {
beego.Error("probe cases could not be created:", err)
@@ -328,20 +345,52 @@ func seedProbeCases() error {
if err != nil {
return err
}
- known := map[string]bool{}
+ known := map[string]*ProbeCase{}
for _, probeCase := range stored {
- known[probeCase.Name] = true
+ known[probeCase.Name] = probeCase
}
+ shipping := map[string]bool{}
for _, probeCase := range builtInProbeCases() {
- if known[probeCase.Name] {
+ shipping[probeCase.Name] = true
+ probeCase.Shipped = probeCaseFingerprint(probeCase)
+
+ existing := known[probeCase.Name]
+ if existing == nil {
+ probeCase.CreatedTime = util.GetCurrentTime()
+ probeCase.UpdatedTime = probeCase.CreatedTime
+ if _, err := ormer.Engine.Insert(probeCase); err != nil {
+ return err
+ }
+ continue
+ }
+ if existing.Edited || existing.Shipped == probeCase.Shipped {
continue
}
- probeCase.CreatedTime = util.GetCurrentTime()
- probeCase.UpdatedTime = probeCase.CreatedTime
- if _, err := ormer.Engine.Insert(probeCase); err != nil {
+ if err := refreshProbeCase(probeCase); err != nil {
+ return err
+ }
+ }
+
+ // A built-in case this release stopped shipping has nothing left to run:
+ // the engine it named or the question it asked is gone.
+ for _, probeCase := range stored {
+ if !probeCase.BuiltIn || probeCase.Edited || shipping[probeCase.Name] {
+ continue
+ }
+ if err := DeleteProbeCase(probeCase.Name); err != nil {
return err
}
}
return nil
}
+
+// refreshProbeCase rewrites the question and leaves the settings: what a case
+// asks is Gateway's, what it is worth and whether it runs at all is not.
+func refreshProbeCase(probeCase *ProbeCase) error {
+ probeCase.UpdatedTime = util.GetCurrentTime()
+ _, err := ormer.Engine.ID(probeCase.Name).
+ Cols("display_name", "check", "protocol", "question", "method", "params", "shipped", "updated_time").
+ Update(probeCase)
+ return err
+}
diff --git a/object/probe_case_builtin.go b/object/probe_case_builtin.go
index c5968ec..4890d23 100644
--- a/object/probe_case_builtin.go
+++ b/object/probe_case_builtin.go
@@ -49,9 +49,11 @@ func builtInProbeCases() []*ProbeCase {
BuiltIn: true,
Question: "Does the model that answers name itself as the one that was asked for?",
Method: "The first request names a model. Every completion API answers with the model that " +
- "ran, and a version or date suffix on the same family counts as a match. A different " +
- "name is decisive; a matching one proves little, since echoing a name back is the " +
- "easiest thing in the world to do.",
+ "ran, and a version or date suffix on the same family counts as a match. Where the " +
+ "name asked for is one the vendor documents as moving — deepseek-chat, anything " +
+ "ending in -latest — the answer only has to be a model of that same vendor, which is " +
+ "what the vendor says it will be. Any other mismatch is decisive; a matching name " +
+ "proves little, since echoing one back is the easiest thing in the world to do.",
},
{
Name: "tools-nested-schema",
@@ -153,40 +155,21 @@ func builtInProbeCases() []*ProbeCase {
},
},
{
- Name: "vendor-headers-anthropic",
- DisplayName: "Vendor headers (Anthropic)",
+ Name: "vendor-headers",
+ DisplayName: "Vendor headers",
Check: ProbeVendor,
- Protocol: ProtocolAnthropic,
Enabled: true,
Weight: probeWeightVendor,
Sort: 60,
BuiltIn: true,
Question: "Do the response headers of the vendor's own API come back?",
- Method: "A reseller in front of the real thing usually passes some of these through; a " +
- "backend that never talked to the vendor has none to pass. This case never reaches an " +
- "alert on its own: stripping headers is being tidy, not necessarily dishonest.",
- Params: ProbeCaseParams{
- Headers: probeVendorHeaders[ProtocolAnthropic],
- MinHeaders: 2,
- },
- },
- {
- Name: "vendor-headers-openai",
- DisplayName: "Vendor headers (OpenAI)",
- Check: ProbeVendor,
- Protocol: ProtocolOpenAi,
- Enabled: true,
- Weight: probeWeightVendor,
- Sort: 61,
- BuiltIn: true,
- Question: "Do the response headers of the vendor's own API come back?",
- Method: "A reseller in front of the real thing usually passes some of these through; a " +
- "backend that never talked to the vendor has none to pass. This case never reaches an " +
- "alert on its own: stripping headers is being tidy, not necessarily dishonest.",
- Params: ProbeCaseParams{
- Headers: probeVendorHeaders[ProtocolOpenAi],
- MinHeaders: 2,
- },
+ Method: "This is asked only of an endpoint that is a vendor's own, against the headers " +
+ "that vendor documents: speaking an OpenAI-compatible API is not a claim to be OpenAI. " +
+ "A reseller in front of the real thing usually passes some through and a backend that " +
+ "never talked to the vendor has none to pass, but stripping headers is also just being " +
+ "tidy, so this case never reaches an alert on its own. Leave the header list empty to " +
+ "use the vendor's own, or name headers here to ask for those instead.",
+ Params: ProbeCaseParams{MinHeaders: 2},
},
}
}
diff --git a/object/provider_probe_checks.go b/object/provider_probe_checks.go
index 3575b55..6b492e5 100644
--- a/object/provider_probe_checks.go
+++ b/object/provider_probe_checks.go
@@ -44,7 +44,6 @@ import (
// first request decides whether the upstream is usable at all; everything after
// it is a question that would be meaningless if it were not.
func runProbeCases(provider *Provider, model string, probe *ProviderProbe, cases []*ProbeCase) {
- upstream := ProviderProtocol(provider)
toolCases := probeCasesOf(cases, ProbeTools)
var primary *probeBody
@@ -77,7 +76,17 @@ func runProbeCases(provider *Provider, model string, probe *ProviderProbe, cases
probe.addCheck(probeIdentityCheck(identityCase, model, primary.Model))
}
for _, vendorCase := range probeCasesOf(cases, ProbeVendor) {
- found := vendorHeadersOf(vendorCase, upstream, headers)
+ wanted := vendorHeadersOf(vendorCase, provider)
+ if len(wanted) == 0 {
+ // Nothing to hold this endpoint to: the vendor it belongs to is
+ // not one whose headers this build documents, or it belongs to no
+ // vendor at all. That is a gap here, not a finding about it.
+ check := checkOf(vendorCase, LlmAuditUnknown)
+ check.Facts = []string{probeVendorUndocumented}
+ probe.addCheck(check)
+ continue
+ }
+ found := headersPresent(wanted, headers)
probe.VendorHeaders = mergeStrings(probe.VendorHeaders, found)
probe.addCheck(probeVendorCheck(vendorCase, found))
}
@@ -329,9 +338,15 @@ func probeOpeningCall(provider *Provider, model string, probe *ProviderProbe) (*
return &parsed, answer.header
}
+// probeIdentityAlias marks an answer that matched because the name asked for is
+// one the vendor documents as moving, so that the report can say which of the
+// two things it found.
+const probeIdentityAlias = "alias"
+
// probeIdentityCheck compares the model name that came back with the one that
// was asked for. Matching proves little on its own — echoing a name back is the
-// easiest thing in the world to do — but not matching is decisive.
+// easiest thing in the world to do — but not matching is decisive, except where
+// the vendor documents the name asked for as an alias.
func probeIdentityCheck(probeCase *ProbeCase, asked string, answered string) ProbeCheck {
check := checkOf(probeCase, LlmAuditUnknown)
if strings.TrimSpace(answered) == "" {
@@ -342,6 +357,17 @@ func probeIdentityCheck(probeCase *ProbeCase, asked string, answered string) Pro
check.Level = LlmAuditOk
return check
}
+
+ // "deepseek-chat" is DeepSeek's own name for whichever chat model is
+ // current, and it answers with the name of the model that ran. A different
+ // name there is the documented behaviour; what is left to check is that it
+ // is still a model of the vendor whose alias was asked for.
+ if isProbeModelAlias(asked) && sameModelVendor(asked, answered) {
+ check.Level = LlmAuditOk
+ check.Facts = []string{answered, probeIdentityAlias}
+ return check
+ }
+
check.Level = LlmAuditAlert
return check
}
@@ -365,32 +391,27 @@ func sameModelName(asked string, answered string) bool {
return strings.HasPrefix(left, right) || strings.HasPrefix(right, left)
}
-// The response headers each vendor's own API sets. A reseller in front of the
-// real thing usually passes some of them through; a backend that never talked
-// to the vendor has none to pass. A case may name its own list instead.
-var probeVendorHeaders = map[string][]string{
- ProtocolAnthropic: {
- "Request-Id",
- "Anthropic-Organization-Id",
- "Anthropic-Ratelimit-Requests-Limit",
- "Anthropic-Ratelimit-Input-Tokens-Limit",
- "X-Should-Retry",
- },
- ProtocolOpenAi: {
- "X-Request-Id",
- "Openai-Organization",
- "Openai-Processing-Ms",
- "Openai-Version",
- "X-Ratelimit-Limit-Requests",
- },
-}
-
-func vendorHeadersOf(probeCase *ProbeCase, upstream string, header http.Header) []string {
- wanted := probeCase.Params.Headers
- if len(wanted) == 0 {
- wanted = probeVendorHeaders[upstream]
+// probeVendorUndocumented is what the header case reports when there is no
+// documented list to hold the endpoint to.
+const probeVendorUndocumented = "undocumented"
+
+// vendorHeadersOf is the list this endpoint is held to: the one the case names,
+// or failing that the response headers the vendor whose own host it is
+// documents. An endpoint that is not a vendor's own — a reseller, an
+// aggregator, a vendor this build carries no header list for — has no list, and
+// the case is then not asked. Answering an OpenAI-compatible API is not a claim
+// to be OpenAI, so OpenAI's headers are not owed by everything that speaks it.
+func vendorHeadersOf(probeCase *ProbeCase, provider *Provider) []string {
+ if len(probeCase.Params.Headers) > 0 {
+ return probeCase.Params.Headers
+ }
+ if vendor := probeVendorOfProvider(provider); vendor != nil {
+ return vendor.headers
}
+ return nil
+}
+func headersPresent(wanted []string, header http.Header) []string {
found := []string{}
for _, name := range wanted {
if header.Get(name) != "" {
diff --git a/object/provider_probe_score.go b/object/provider_probe_score.go
index 077fe51..9d25202 100644
--- a/object/provider_probe_score.go
+++ b/object/provider_probe_score.go
@@ -100,7 +100,7 @@ func probeScoreOf(probe *ProviderProbe) (float64, string) {
return 0, ProbeGradeUnknown
}
- score := math.Round(earned / possible * 1000) / 10
+ score := math.Round(earned/possible*1000) / 10
return score, probeGradeOf(score)
}
diff --git a/object/provider_probe_vendor.go b/object/provider_probe_vendor.go
new file mode 100644
index 0000000..7fabe6c
--- /dev/null
+++ b/object/provider_probe_vendor.go
@@ -0,0 +1,221 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// What each vendor documents about itself, which is the only thing a check is
+// entitled to hold it to. An endpoint or a model name that matches nothing here
+// is not a finding: it is a question this build cannot ask.
+
+package object
+
+import (
+ "net/url"
+ "strings"
+)
+
+type probeVendor struct {
+ key string
+ // hosts are the vendor's own API hosts. A reseller answers on its own host
+ // and so matches none of them, which is the point.
+ hosts []string
+ // models are the prefixes of the model names this vendor serves.
+ models []string
+ // aliases are names the vendor documents as pointing at whichever model is
+ // current. Asking for one and being answered with a different name is what
+ // the vendor says will happen, so it is not evidence of anything.
+ aliases []string
+ // headers are the response headers this vendor's own API sets. Empty means
+ // this build does not document them, which leaves the header case
+ // unmeasured rather than failed.
+ headers []string
+}
+
+// The entries below were each read off the vendor's own API reference. A vendor
+// is here for what it documents, not for being trusted: an official endpoint
+// scores no better for being official.
+var probeVendors = []probeVendor{
+ {
+ key: "openai",
+ hosts: []string{"api.openai.com"},
+ models: []string{"gpt-", "chatgpt-", "o1", "o3", "o4", "codex-", "text-embedding-"},
+ aliases: []string{"chatgpt-4o-latest"},
+ headers: []string{
+ "X-Request-Id",
+ "Openai-Organization",
+ "Openai-Processing-Ms",
+ "Openai-Version",
+ "X-Ratelimit-Limit-Requests",
+ },
+ },
+ {
+ key: "anthropic",
+ hosts: []string{"api.anthropic.com"},
+ models: []string{"claude-"},
+ headers: []string{
+ "Request-Id",
+ "Anthropic-Organization-Id",
+ "Anthropic-Ratelimit-Requests-Limit",
+ "Anthropic-Ratelimit-Input-Tokens-Limit",
+ "X-Should-Retry",
+ },
+ },
+ {
+ key: "deepseek",
+ hosts: []string{"api.deepseek.com"},
+ // "deepseek-chat" is the non-thinking model of the day and
+ // "deepseek-reasoner" the thinking one; which model that is changes
+ // with every release, and the answer carries the name it changed to.
+ models: []string{"deepseek"},
+ aliases: []string{"deepseek-chat", "deepseek-reasoner"},
+ },
+ {
+ key: "moonshot",
+ hosts: []string{"api.moonshot.cn", "api.moonshot.ai"},
+ models: []string{"kimi", "moonshot-"},
+ aliases: []string{"kimi-latest"},
+ },
+ {
+ key: "zhipu",
+ hosts: []string{"open.bigmodel.cn"},
+ models: []string{"glm-", "charglm-", "codegeex-"},
+ },
+ {
+ key: "qwen",
+ hosts: []string{"dashscope.aliyuncs.com", "dashscope-intl.aliyuncs.com"},
+ models: []string{"qwen", "qwq", "qvq"},
+ },
+ {
+ key: "xai",
+ hosts: []string{"api.x.ai"},
+ models: []string{"grok-"},
+ },
+ {
+ key: "google",
+ hosts: []string{"generativelanguage.googleapis.com"},
+ models: []string{"gemini-", "gemma-"},
+ },
+ {
+ key: "mistral",
+ hosts: []string{"api.mistral.ai"},
+ models: []string{
+ "mistral-", "ministral-", "magistral-", "codestral-", "devstral-", "pixtral-", "open-mistral-",
+ },
+ },
+ {
+ key: "minimax",
+ hosts: []string{"api.minimaxi.com", "api.minimax.chat", "api.minimaxi.chat"},
+ models: []string{"minimax-", "abab"},
+ },
+ {
+ key: "stepfun",
+ hosts: []string{"api.stepfun.com", "api.stepfun.ai"},
+ models: []string{"step-"},
+ },
+}
+
+// probeVendorOfProvider is the vendor whose own endpoint this provider points
+// at. Anything else — a reseller, an aggregator, a private deployment — is nil:
+// its answers are its own, not a vendor's to be checked against.
+func probeVendorOfProvider(provider *Provider) *probeVendor {
+ parsed, err := url.Parse(strings.TrimSpace(provider.BaseUrl))
+ if err != nil {
+ return nil
+ }
+
+ host := strings.ToLower(parsed.Hostname())
+ if host == "" {
+ return nil
+ }
+ for index := range probeVendors {
+ for _, candidate := range probeVendors[index].hosts {
+ if host == candidate || strings.HasSuffix(host, "."+candidate) {
+ return &probeVendors[index]
+ }
+ }
+ }
+ return nil
+}
+
+// probeVendorOfModel is the vendor a model name belongs to, which is a separate
+// question from whose endpoint served it: a reseller passing DeepSeek through
+// is still answering with DeepSeek's model names.
+func probeVendorOfModel(name string) *probeVendor {
+ model := probeModelName(name)
+ if model == "" {
+ return nil
+ }
+ for index := range probeVendors {
+ for _, prefix := range probeVendors[index].models {
+ if strings.HasPrefix(model, prefix) {
+ return &probeVendors[index]
+ }
+ }
+ }
+ return nil
+}
+
+// isProbeModelAlias reports whether this name is one the vendor documents as
+// moving. "-latest" says so in the name itself; the rest are listed above.
+func isProbeModelAlias(name string) bool {
+ model := probeModelName(name)
+ if model == "" {
+ return false
+ }
+ if strings.HasSuffix(model, "-latest") {
+ return true
+ }
+
+ vendor := probeVendorOfModel(model)
+ if vendor == nil {
+ return false
+ }
+ for _, alias := range vendor.aliases {
+ if model == alias {
+ return true
+ }
+ }
+ return false
+}
+
+// sameModelVendor reports whether two names come out of the same catalogue: the
+// vendor that owns both prefixes where one is known, and the leading segment of
+// the name otherwise, which is how the vendors that are not listed above still
+// name their own models.
+func sameModelVendor(asked string, answered string) bool {
+ left, right := probeModelName(asked), probeModelName(answered)
+ if left == "" || right == "" {
+ return false
+ }
+
+ if vendor := probeVendorOfModel(left); vendor != nil {
+ return probeVendorOfModel(right) == vendor
+ }
+ return probeModelSegment(left) == probeModelSegment(right)
+}
+
+// probeModelName is the model as the vendor names it, with the vendor prefix an
+// aggregator adds ("deepseek/deepseek-chat") taken off.
+func probeModelName(raw string) string {
+ name := strings.ToLower(strings.TrimSpace(raw))
+ if index := strings.LastIndex(name, "/"); index >= 0 {
+ name = name[index+1:]
+ }
+ return name
+}
+
+func probeModelSegment(name string) string {
+ if index := strings.Index(name, "-"); index > 0 {
+ return name[:index]
+ }
+ return name
+}
diff --git a/web/src/components/ProviderAuditCard.tsx b/web/src/components/ProviderAuditCard.tsx
index d94217b..4e14b93 100644
--- a/web/src/components/ProviderAuditCard.tsx
+++ b/web/src/components/ProviderAuditCard.tsx
@@ -159,7 +159,11 @@ function probeValue(check: ProbeCheck) {
*/
function probeDetail(check: ProbeCheck, probe: ProviderProbe) {
if (check.level === "unknown" && check.key !== "identity") {
- return i18next.t("audit:This question could not be asked");
+ // An endpoint that is nobody's own has no documented headers to be missing,
+ // which is a gap in what Gateway knows rather than one in the answer.
+ return check.facts[0] === "undocumented"
+ ? i18next.t("audit:Vendor undocumented")
+ : i18next.t("audit:This question could not be asked");
}
switch (check.key) {
@@ -167,6 +171,9 @@ function probeDetail(check: ProbeCheck, probe: ProviderProbe) {
if (check.facts.length === 0) {
return i18next.t("audit:No model name came back");
}
+ if (check.facts[1] === "alias") {
+ return fill("audit:Identity alias", {asked: probe.model, answered: check.facts[0]});
+ }
return check.level === "ok"
? fill("audit:Identity ok", {answered: check.facts[0]})
: fill("audit:Identity different", {asked: probe.model, answered: check.facts[0]});
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 706bcd4..9a9ccf9 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -433,6 +433,7 @@
"Headers looked for": "Headers looked for",
"Headers required": "Headers required",
"Held": "Held",
+ "Identity alias": "Asked for {asked} and {answered} answered, which is the model this vendor documents that name as pointing at.",
"Identity different": "Asked for {asked} and the answer named {answered}.",
"Identity has no request": "This case sends nothing of its own: it reads the model name off the first answer of the run.",
"Identity ok": "Answered as {answered}. Echoing a name back is easy, so this is a floor rather than a proof.",
@@ -523,6 +524,7 @@
"User prompt": "User prompt",
"Vendor detail": "Carries {names}.",
"Vendor headers": "Vendor headers",
+ "Vendor undocumented": "Not asked: this endpoint is not a vendor's own, so there is no documented header list to hold it to.",
"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.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index d9b3fea..1786186 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -433,6 +433,7 @@
"Headers looked for": "查找的响应头",
"Headers required": "至少要有几个",
"Held": "接住了",
+ "Identity alias": "请求的是 {asked},回答的是 {answered},而这正是该厂商在文档里写明这个名字指向的模型。",
"Identity different": "请求的是 {asked},返回里写的是 {answered}。",
"Identity has no request": "这条用例不单独发请求:它读的是本次探测第一个回答里的模型名。",
"Identity ok": "返回 {answered}。把名字原样回显是最容易做到的事,所以这只是底线,不是证明。",
@@ -523,6 +524,7 @@
"User prompt": "User 提示词",
"Vendor detail": "带有 {names}。",
"Vendor headers": "厂商响应头",
+ "Vendor undocumented": "未发问:这个地址不是厂商自己的域名,没有可供比对的响应头清单。",
"Warn outside": "超出则存疑",
"Weight": "权重",
"Weight hint": "这条用例在评分中占的分量,与同一次探测中其他被测用例相比。",
@@ -535,7 +537,7 @@
"case.cache-identical-prefix.method": "同一段长提示词逐字节相同地发两次,间隔一秒半。真缓存会在第一次写入、第二次读回。两个计数器都是零,就说明整段前缀两次都按新输入计费了。",
"case.cache-identical-prefix.question": "缓存是真的,还是把同一段前缀当新输入收了两遍钱?",
"case.identity-model-name.displayName": "模型身份",
- "case.identity-model-name.method": "第一个请求会点名一个模型。所有补全 API 都会在回答里带上实际执行的模型名,同一系列的版本号或日期后缀算匹配。名字不一样是铁证;名字一样说明不了太多——把名字原样回显是天底下最容易的事。",
+ "case.identity-model-name.method": "第一个请求点名一个模型。所有补全 API 都在回答里写明实际执行的模型,同一系列的版本号或日期后缀算匹配。如果请求的名字本就是厂商声明会变的别名——比如 deepseek-chat,或任何以 -latest 结尾的——那回答只需要仍是该厂商的模型,这正是厂商自己文档里写的。除此以外的不一致就是硬证据;而名字对上了并说明不了太多——把名字原样拄回来是最容易做到的事。",
"case.identity-model-name.question": "作答的模型自报的名字,是不是请求里点名的那个?",
"case.stream-events-anthropic.displayName": "流式结构(Anthropic)",
"case.stream-events-anthropic.method": "同样的请求开着流式再发一次。把别的 API 翻译成这套 API 的后端,往往是文字还没露馅、信封先错了:开头和结尾的事件不见了,或者 message_start 里没有输入计数,因为那时它还什么都没数。",
@@ -546,12 +548,9 @@
"case.tools-nested-schema.displayName": "嵌套工具 Schema",
"case.tools-nested-schema.method": "工具调用是强制的,不许拒答,而且 Schema 在两层上都有必填字段,其中一层是对象数组。冒充被售模型的小模型通常会把对象拍平,或者干脆丢掉数组。",
"case.tools-nested-schema.question": "在强制调用的情况下,作答的东西能不能撑住两层嵌套的工具 Schema?",
- "case.vendor-headers-anthropic.displayName": "厂商响应头(Anthropic)",
- "case.vendor-headers-anthropic.method": "挡在真服务前面的中转通常会透传一部分;从来没跟厂商说过话的后端则一个也拿不出来。这条用例自己不会判到报警级别:把响应头抹掉是干净,不一定是心虚。",
- "case.vendor-headers-anthropic.question": "厂商自己 API 的那些响应头回来了吗?",
- "case.vendor-headers-openai.displayName": "厂商响应头(OpenAI)",
- "case.vendor-headers-openai.method": "挡在真服务前面的中转通常会透传一部分;从来没跟厂商说过话的后端则一个也拿不出来。这条用例自己不会判到报警级别:把响应头抹掉是干净,不一定是心虚。",
- "case.vendor-headers-openai.question": "厂商自己 API 的那些响应头回来了吗?"
+ "case.vendor-headers.displayName": "厂商响应头",
+ "case.vendor-headers.method": "这一条只向厂商自己的域名发问,比对的也是该厂商文档里写明的响应头:能说 OpenAI 兼容的 API,不等于自称是 OpenAI。真货前面的转售商通常会透传一部分,而从没连过厂商的后端一个也拿不出来;但把响应头抹掉也可能只是干净,所以这一条单独不会判定为异常。响应头列表留空就用该厂商自己的,也可以在这里指定要检查哪些响应头。",
+ "case.vendor-headers.question": "厂商自己 API 的那些响应头回来了吗?"
},
"general": {
"Action": "操作",
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org