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 e0afc51c71c81ca05bb47cc8bfc8aceaf1c0ec86
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 25 18:50:29 2026 +0800

    feat(probe): grade one endpoint and save a shareable card without 
installing Gateway
---
 .github/workflows/nightly.yml     |  15 ++-
 README.md                         |  12 +-
 README_zh.md                      |  12 +-
 cmd/gateway-probe/main.go         |  29 ++++
 go.mod                            |   1 +
 go.sum                            |   5 +
 main.go                           |   6 +
 object/provider_probe_endpoint.go |  38 ++++++
 probecmd/card.go                  | 269 ++++++++++++++++++++++++++++++++++++++
 probecmd/detail.go                | 212 ++++++++++++++++++++++++++++++
 probecmd/lang_other.go            |  21 +++
 probecmd/lang_windows.go          |  38 ++++++
 probecmd/probecmd.go              | 229 ++++++++++++++++++++++++++++++++
 probecmd/report.go                | 129 ++++++++++++++++++
 probecmd/text.go                  | 148 +++++++++++++++++++++
 15 files changed, 1161 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 8419aae..d535ff8 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -190,6 +190,15 @@ jobs:
                 ;;
             esac
 
+            # The probe on its own, as a bare file: grading an endpoint should
+            # not mean unpacking Gateway.
+            probeBinary="dist/gateway-probe-${goos}-${archName}"
+            if [[ "${goos}" == "windows" ]]; then
+              probeBinary="${probeBinary}.exe"
+            fi
+            GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED=0 \
+              go build -trimpath -ldflags "-s -w" -o "${probeBinary}" 
./cmd/gateway-probe
+
             cp LICENSE NOTICE DISCLAIMER README.md "${stage}/"
 
             if [[ "${goos}" == "windows" ]]; then
@@ -238,6 +247,10 @@ jobs:
           | Linux x86_64 | `BASENAME_PLACEHOLDER-linux-x86_64.tar.gz` |
           | macOS x86_64 | `BASENAME_PLACEHOLDER-darwin-x86_64.tar.gz` |
           | Windows x86_64 | `BASENAME_PLACEHOLDER-windows-x86_64.zip` |
+
+          `gateway-probe-*` are the authenticity probe on its own: one file 
that grades an
+          OpenAI- or Anthropic-compatible endpoint and saves a shareable card, 
with nothing
+          installed. Run it with `--help`.
           NOTES
 
           sed -i "s/BASENAME_PLACEHOLDER/${BASENAME}/g" release-notes.md
@@ -265,4 +278,4 @@ jobs:
             --target "${GITHUB_SHA}" \
             --title "Nightly build (not an official release)" \
             --notes-file release-notes.md \
-            dist/*.tar.gz dist/*.zip
+            dist/*.tar.gz dist/*.zip dist/gateway-probe-*
diff --git a/README.md b/README.md
index 37d4f5c..10e5ab1 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@
   <a href="https://cdn.casbin.org/img/casbin-gateway.gif";><img alt="Casbin 
Gateway" src="https://cdn.casbin.org/img/casbin-gateway.gif"; width="900"></a>
 </p>
 
-- **Is that API key what it was sold as?** A reseller can serve a cheaper 
model, fake a cache hit, or quietly drop a parameter it claims to support. 
[Authenticity](#the-killer-feature-is-the-api-behind-that-key-what-it-was-sold-as)
 probes every provider on its own and grades it A–F.
+- **Is that API key what it was sold as?** A reseller can serve a cheaper 
model, fake a cache hit, or quietly drop a parameter it claims to support. 
[Authenticity](#the-killer-feature-is-the-api-behind-that-key-what-it-was-sold-as)
 probes every provider on its own and grades it A–F, and [grades a single 
endpoint](#check-one-endpoint-without-installing-anything) without installing 
anything.
 - **What every agent did, spent and sent.** Every prompt and tool call, the 
spend read from the agents' own transcripts, and, on Windows, where each 
agent's own processes upload data, including what never passes through any 
proxy.
 - **One place for all of them.** Point Claude Code, Codex, Cursor, Gemini CLI 
and the rest at any of 44 model vendors, say what each may do, and install, 
upgrade or roll back the agents themselves.
 
@@ -249,6 +249,16 @@ to answer, and how much of what it served has no price.
 
 A probe spends a few cents of that provider's own credit, which is on the 
report next to the finding. `providerProbeIntervalHours` sets how often a 
report goes stale, `providerProbeMode = "manual"` probes only when asked, and 
`"off"` never probes.
 
+#### Check one endpoint without installing anything
+
+The same suite runs on its own against any OpenAI- or Anthropic-compatible 
endpoint: a report in the terminal, and a PNG card you can post. Nothing is 
stored, and the key never leaves the requests the probe makes.
+
+```bash
+GATEWAY_PROBE_KEY=sk-... go run 
github.com/apache/casbin-gateway/cmd/gateway-probe@master 
https://relay.example.com --model claude-sonnet-4-5
+```
+
+No Go? Download the single `gateway-probe` file for your platform from the 
[nightly build](https://github.com/apache/casbin-gateway/releases/tag/nightly) 
and run it the same way. Gateway itself answers to `casbin-gateway probe` too. 
`--hide-host` masks the endpoint on the report and the card, `--json` prints 
every request and answer the grade was drawn from, and `--help` lists the rest.
+
 ### Send an agent's traffic through Gateway
 
 This is what fills **LLM Records**, and what lets Gateway keep the vendor key 
instead of the agent.
diff --git a/README_zh.md b/README_zh.md
index d80e29f..4b2012a 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -99,7 +99,7 @@
   <a href="https://cdn.casbin.org/img/casbin-gateway.gif";><img alt="Casbin 
Gateway" src="https://cdn.casbin.org/img/casbin-gateway.gif"; width="900"></a>
 </p>
 
-- **这个API Key,真是卖给你的那个吗?** 
中转商可以拿便宜模型冒充、伪造缓存命中,或者悄悄丢掉它声称支持的参数。[真伪检测](#杀手锏那个-key-背后的-api真是卖给你的那个吗)会主动探测每个Provider,给出A–F评级。
+- **这个API Key,真是卖给你的那个吗?** 
中转商可以拿便宜模型冒充、伪造缓存命中,或者悄悄丢掉它声称支持的参数。[真伪检测](#杀手锏那个-key-背后的-api真是卖给你的那个吗)会主动探测每个Provider,给出A–F评级;[不安装也能单测一个接口](#不安装单测一个接口)。
 - **每个Agent做了什么、花了多少、发到了哪里。** 
每条提示词和工具调用,从Agent自己的会话记录里读出的花费;在Windows上还能看到每个Agent自己的进程把数据上传到了哪里,包括任何代理都看不到的那部分。
 - **所有Agent,一个地方管。** 把Claude Code、Codex、Cursor、Gemini 
CLI等接到44个模型厂商中的任意一个,规定每个Agent能做什么,并直接安装、升级或回滚这些Agent本身。
 
@@ -246,6 +246,16 @@ Agent 是通过读取 **Gateway 所在机器**的用户账户、home 目录和
 
 一次探测会花掉该 Provider 一点点额度,具体花了多少就写在报告上。`providerProbeIntervalHours` 
决定报告多久算过期,`providerProbeMode = "manual"` 表示只在手动触发时探测,`"off"` 表示从不探测。
 
+#### 不安装,单测一个接口
+
+同一套用例可以单独跑,对象是任何OpenAI或Anthropic兼容的接口:终端里出一份报告,同时存一张可以直接发帖的PNG卡片。什么都不落盘,Key只用在探测自己发出的请求里。
+
+```bash
+GATEWAY_PROBE_KEY=sk-... go run 
github.com/apache/casbin-gateway/cmd/gateway-probe@master 
https://relay.example.com --model claude-sonnet-4-5
+```
+
+没装Go的话,从[nightly构建](https://github.com/apache/casbin-gateway/releases/tag/nightly)下载对应平台的`gateway-probe`单文件,用法相同。已经装了Gateway的,直接`casbin-gateway
 probe`。`--hide-host`会在报告和卡片上打码接口地址,`--json`输出评级依据的每个请求和返回,其余参数见`--help`。
+
 ### 让 Agent 的流量走 Gateway
 
 这是 **LLM Records** 的数据来源,也是让 Gateway 而不是 Agent 持有厂商 Key 的方式。
diff --git a/cmd/gateway-probe/main.go b/cmd/gateway-probe/main.go
new file mode 100644
index 0000000..c355dad
--- /dev/null
+++ b/cmd/gateway-probe/main.go
@@ -0,0 +1,29 @@
+// 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.
+
+// gateway-probe is Gateway's authenticity probe on its own, for grading an
+// endpoint without installing Gateway:
+//
+//     go run github.com/apache/casbin-gateway/cmd/gateway-probe@latest 
https://relay.example.com --model claude-sonnet-4-5
+package main
+
+import (
+       "os"
+
+       "github.com/apache/casbin-gateway/probecmd"
+)
+
+func main() {
+       os.Exit(probecmd.Run(os.Args[1:], os.Stdout, os.Stderr))
+}
diff --git a/go.mod b/go.mod
index d031984..3b25901 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
        github.com/xorm-io/core v0.7.4
        github.com/xorm-io/xorm v1.1.6
        golang.org/x/crypto v0.19.0
+       golang.org/x/image v0.14.0
        golang.org/x/oauth2 v0.13.0
        golang.org/x/sys v0.17.0
        gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
index 56a41dd..fbf3d80 100644
--- a/go.sum
+++ b/go.sum
@@ -97,6 +97,7 @@ github.com/google/go-cmp v0.4.0/go.mod 
h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.3/go.mod 
h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod 
h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod 
h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/gofuzz v1.0.0/go.mod 
h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/uuid v1.3.0/go.mod 
h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -256,6 +257,8 @@ golang.org/x/crypto 
v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP
 golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
 golang.org/x/crypto v0.19.0/go.mod 
h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/exp v0.0.0-20181106170214-d68db9428509/go.mod 
h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
+golang.org/x/image v0.14.0/go.mod 
h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
 golang.org/x/lint v0.0.0-20190930215403-16217165b5de 
h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod 
h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -406,9 +409,11 @@ modernc.org/strutil v1.1.1 
h1:xv+J1BXY3Opl2ALrBwyfEikFAj8pmqcpnfmuwUwcozs=
 modernc.org/strutil v1.1.1/go.mod 
h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
 modernc.org/tcl v1.5.0/go.mod h1:gb57hj4pO8fRrK54zveIfFXBaMHK3SKJNWcmRw1cRzc=
 modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
+modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
 modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk=
 modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
 modernc.org/y v1.0.1/go.mod h1:Ho86I+LVHEI+LYXoUKlmOMAM1JTXOCfj8qi1T8PsClE=
 modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod 
h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
 modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
 modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=
+modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
diff --git a/main.go b/main.go
index ad6cd84..b0108fa 100644
--- a/main.go
+++ b/main.go
@@ -32,6 +32,7 @@ import (
        "github.com/apache/casbin-gateway/mcpproxy"
        "github.com/apache/casbin-gateway/mcpserver"
        "github.com/apache/casbin-gateway/object"
+       "github.com/apache/casbin-gateway/probecmd"
        "github.com/apache/casbin-gateway/proxy"
        "github.com/apache/casbin-gateway/routers"
        "github.com/apache/casbin-gateway/service"
@@ -61,6 +62,11 @@ func main() {
                return
        }
 
+       // "probe" grades one endpoint and exits, touching no database.
+       if probecmd.RunCommand(os.Args) {
+               return
+       }
+
        // "start", "stop" and "status" manage a Gateway running in the 
background,
        // so that using it does not mean keeping a terminal open.
        if util.RunCommand(os.Args, conf.GetHttpPort(), daemonLogPath) {
diff --git a/object/provider_probe_endpoint.go 
b/object/provider_probe_endpoint.go
new file mode 100644
index 0000000..3d40def
--- /dev/null
+++ b/object/provider_probe_endpoint.go
@@ -0,0 +1,38 @@
+// 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.
+
+package object
+
+import "strings"
+
+// ProbeEndpoint runs the shipped suite against a bare endpoint and key. 
Nothing
+// is stored: this is the probe for someone who has not installed Gateway.
+func ProbeEndpoint(baseUrl string, apiKey string, protocol string, model 
string) *ProviderProbe {
+       provider := &Provider{
+               Owner:    "probe",
+               Name:     "endpoint",
+               Type:     "custom",
+               BaseUrl:  strings.TrimSpace(baseUrl),
+               ApiKey:   strings.TrimSpace(apiKey),
+               AuthMode: ProviderAuthProvider,
+               Status:   "enabled",
+       }
+       if protocol == ProtocolAnthropic {
+               provider.Type = "anthropic"
+       }
+       if model = strings.TrimSpace(model); model != "" {
+               provider.Models = []string{model}
+       }
+       return runProviderProbe(provider, ProbeTriggerManual)
+}
diff --git a/probecmd/card.go b/probecmd/card.go
new file mode 100644
index 0000000..b50e68e
--- /dev/null
+++ b/probecmd/card.go
@@ -0,0 +1,269 @@
+// 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.
+
+package probecmd
+
+import (
+       "fmt"
+       "image"
+       "image/color"
+       "image/png"
+       "math"
+       "os"
+       "time"
+
+       "github.com/apache/casbin-gateway/object"
+       "golang.org/x/image/font"
+       "golang.org/x/image/font/gofont/gobold"
+       "golang.org/x/image/font/gofont/gomono"
+       "golang.org/x/image/font/gofont/goregular"
+       "golang.org/x/image/font/opentype"
+       "golang.org/x/image/math/fixed"
+)
+
+// The card is the size link previews use, in the dark half of the web UI's
+// Amber & Ink palette. It stays in English: the bundled Go fonts have no CJK.
+const (
+       cardWidth  = 1200
+       cardHeight = 630
+       cardMargin = 64
+)
+
+var (
+       cardBackground = rgb(0x14100c)
+       cardPanel      = rgb(0x1e1915)
+       cardBorder     = rgb(0x322d28)
+       cardText       = rgb(0xf3f0ea)
+       cardMuted      = rgb(0xa9a298)
+       cardPrimary    = rgb(0xef8e38)
+       cardSuccess    = rgb(0x63c180)
+       cardWarning    = rgb(0xd6b651)
+       cardDanger     = rgb(0xf3625d)
+)
+
+func rgb(hex uint32) color.RGBA {
+       return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: 
uint8(hex), A: 0xff}
+}
+
+type cardFaces struct {
+       label, host, grade, score, body, small, mono font.Face
+}
+
+func loadCardFaces() (*cardFaces, error) {
+       faceOf := func(ttf []byte, size float64) (font.Face, error) {
+               parsed, err := opentype.Parse(ttf)
+               if err != nil {
+                       return nil, err
+               }
+               return opentype.NewFace(parsed, &opentype.FaceOptions{Size: 
size, DPI: 72, Hinting: font.HintingFull})
+       }
+
+       faces := &cardFaces{}
+       specs := []struct {
+               target *font.Face
+               ttf    []byte
+               size   float64
+       }{
+               {&faces.label, gobold.TTF, 20},
+               {&faces.host, gobold.TTF, 40},
+               {&faces.grade, gobold.TTF, 220},
+               {&faces.score, gobold.TTF, 34},
+               {&faces.body, goregular.TTF, 21},
+               {&faces.small, goregular.TTF, 19},
+               {&faces.mono, gomono.TTF, 19},
+       }
+       for _, spec := range specs {
+               face, err := faceOf(spec.ttf, spec.size)
+               if err != nil {
+                       return nil, err
+               }
+               *spec.target = face
+       }
+       return faces, nil
+}
+
+func writeCard(path string, probe *object.ProviderProbe, host string, protocol 
string) error {
+       faces, err := loadCardFaces()
+       if err != nil {
+               return err
+       }
+       canvas := image.NewRGBA(image.Rect(0, 0, cardWidth, cardHeight))
+       fillRoundRect(canvas, 0, 0, cardWidth, cardHeight, 0, cardBackground)
+
+       // The grade panel.
+       panelRight := 420
+       fillRoundRect(canvas, cardMargin-24, 150, panelRight, cardHeight-96, 
20, cardBorder)
+       fillRoundRect(canvas, cardMargin-23, 151, panelRight-1, cardHeight-97, 
19, cardPanel)
+       gradeColor := gradeColorOf(probe.Grade)
+       centerText(canvas, faces.grade, (cardMargin-24+panelRight)/2, 370, 
gradeColor, probe.Grade)
+       centerText(canvas, faces.score, (cardMargin-24+panelRight)/2, 430, 
cardText, fmt.Sprintf("%.1f / 100", probe.Score))
+       centerText(canvas, faces.small, (cardMargin-24+panelRight)/2, 470, 
cardMuted,
+               fitText(faces.small, english.gradeMeaning(probe.Grade), 
panelRight-cardMargin-8))
+       counts := map[string]int{}
+       for _, check := range probe.Checks {
+               counts[check.Level]++
+       }
+       centerText(canvas, faces.small, (cardMargin-24+panelRight)/2, 500, 
cardMuted, fmt.Sprintf("%d pass  ·  %d warn  ·  %d fail",
+               counts[object.LlmAuditOk], counts[object.LlmAuditWarn], 
counts[object.LlmAuditAlert]))
+
+       // The header.
+       drawText(canvas, faces.label, cardMargin, 64, cardPrimary, "API 
AUTHENTICITY PROBE")
+       drawText(canvas, faces.host, cardMargin, 114, cardText, 
fitText(faces.host, host, 860))
+       protocolName := "OpenAI API"
+       if protocol == object.ProtocolAnthropic {
+               protocolName = "Anthropic API"
+       }
+       tagWidth := font.MeasureString(faces.small, protocolName).Ceil() + 28
+       tagLeft := cardWidth - cardMargin - tagWidth
+       fillRoundRect(canvas, tagLeft, 84, tagLeft+tagWidth, 122, 19, 
cardBorder)
+       drawText(canvas, faces.small, tagLeft+14, 110, cardMuted, protocolName)
+
+       // The models and the checks.
+       left := panelRight + 44
+       right := cardWidth - cardMargin
+       answered := probe.UpstreamModel
+       if answered == "" {
+               answered = "-"
+       }
+       answeredColor := cardText
+       for _, check := range probe.Checks {
+               if check.Key == object.ProbeIdentity && check.Level != 
object.LlmAuditOk {
+                       answeredColor = levelColorOf(check.Level)
+               }
+       }
+       valueLeft := left + font.MeasureString(faces.small, "answered").Ceil() 
+ 14
+       drawText(canvas, faces.small, left, 184, cardMuted, "asked")
+       drawText(canvas, faces.mono, valueLeft, 184, cardText, 
fitText(faces.mono, probe.Model, right-valueLeft))
+       drawText(canvas, faces.small, left, 214, cardMuted, "answered")
+       drawText(canvas, faces.mono, valueLeft, 214, answeredColor, 
fitText(faces.mono, answered, right-valueLeft))
+
+       columnWidth := (right - left) / 2
+       rows := (len(probe.Checks) + 1) / 2
+       rowHeight := 30
+       if rows > 0 {
+               rowHeight = min(34, (cardHeight-96-258)/rows)
+       }
+       for index, check := range probe.Checks {
+               column, row := index/max(rows, 1), index%max(rows, 1)
+               x := left + column*columnWidth
+               y := 262 + row*rowHeight
+               fillCircle(canvas, float64(x+7), float64(y-7), 7, 
levelColorOf(check.Level))
+               drawText(canvas, faces.body, x+24, y, cardText, 
fitText(faces.body, english.caseTitle(check), columnWidth-36))
+       }
+
+       // The footer.
+       cost := "cost unknown"
+       if probe.Priced {
+               cost = fmt.Sprintf("$%.4f", probe.Cost)
+       }
+       footer := fmt.Sprintf("%s  ·  %d requests  ·  %s", 
time.Now().Format("2006-01-02"), probe.Requests, cost)
+       drawText(canvas, faces.small, cardMargin, cardHeight-44, cardMuted, 
footer)
+       project := "github.com/apache/casbin-gateway"
+       drawText(canvas, faces.label, right-font.MeasureString(faces.label, 
project).Ceil(), cardHeight-44, cardPrimary, project)
+
+       file, err := os.Create(path)
+       if err != nil {
+               return err
+       }
+       if err := png.Encode(file, canvas); err != nil {
+               file.Close()
+               return err
+       }
+       return file.Close()
+}
+
+func gradeColorOf(grade string) color.RGBA {
+       switch grade {
+       case object.ProbeGradeA, object.ProbeGradeB:
+               return cardSuccess
+       case object.ProbeGradeC:
+               return cardWarning
+       default:
+               return cardDanger
+       }
+}
+
+func levelColorOf(level string) color.RGBA {
+       switch level {
+       case object.LlmAuditOk:
+               return cardSuccess
+       case object.LlmAuditWarn:
+               return cardWarning
+       case object.LlmAuditAlert:
+               return cardDanger
+       default:
+               return cardBorder
+       }
+}
+
+func drawText(canvas *image.RGBA, face font.Face, x int, y int, ink 
color.RGBA, text string) {
+       drawer := &font.Drawer{Dst: canvas, Src: image.NewUniform(ink), Face: 
face, Dot: fixed.P(x, y)}
+       drawer.DrawString(text)
+}
+
+func centerText(canvas *image.RGBA, face font.Face, centerX int, y int, ink 
color.RGBA, text string) {
+       drawText(canvas, face, centerX-font.MeasureString(face, text).Ceil()/2, 
y, ink, text)
+}
+
+// fitText cuts a value that would run past width and marks the cut.
+func fitText(face font.Face, text string, width int) string {
+       if font.MeasureString(face, text).Ceil() <= width {
+               return text
+       }
+       runes := []rune(text)
+       for len(runes) > 0 {
+               runes = runes[:len(runes)-1]
+               if font.MeasureString(face, string(runes)+"…").Ceil() <= width {
+                       break
+               }
+       }
+       return string(runes) + "…"
+}
+
+// fillRoundRect and fillCircle blend by how much of each pixel the shape
+// covers, which is what keeps their edges smooth.
+func fillRoundRect(canvas *image.RGBA, x0 int, y0 int, x1 int, y1 int, radius 
float64, ink color.RGBA) {
+       for y := y0; y < y1; y++ {
+               for x := x0; x < x1; x++ {
+                       coverage := 1.0
+                       if radius >= 1 {
+                               px, py := float64(x)+0.5, float64(y)+0.5
+                               cx := math.Max(float64(x0)+radius, math.Min(px, 
float64(x1)-radius))
+                               cy := math.Max(float64(y0)+radius, math.Min(py, 
float64(y1)-radius))
+                               coverage = math.Max(0, math.Min(1, 
radius-math.Hypot(px-cx, py-cy)+0.5))
+                       }
+                       blend(canvas, x, y, ink, coverage)
+               }
+       }
+}
+
+func fillCircle(canvas *image.RGBA, cx float64, cy float64, radius float64, 
ink color.RGBA) {
+       for y := int(cy - radius - 1); y <= int(cy+radius+1); y++ {
+               for x := int(cx - radius - 1); x <= int(cx+radius+1); x++ {
+                       distance := math.Hypot(float64(x)+0.5-cx, 
float64(y)+0.5-cy)
+                       blend(canvas, x, y, ink, math.Max(0, math.Min(1, 
radius-distance+0.5)))
+               }
+       }
+}
+
+func blend(canvas *image.RGBA, x int, y int, ink color.RGBA, coverage float64) 
{
+       if coverage <= 0 || !(image.Point{X: x, Y: y}).In(canvas.Rect) {
+               return
+       }
+       under := canvas.RGBAAt(x, y)
+       mix := func(top uint8, bottom uint8) uint8 {
+               return uint8(math.Round(float64(top)*coverage + 
float64(bottom)*(1-coverage)))
+       }
+       canvas.SetRGBA(x, y, color.RGBA{R: mix(ink.R, under.R), G: mix(ink.G, 
under.G), B: mix(ink.B, under.B), A: 0xff})
+}
diff --git a/probecmd/detail.go b/probecmd/detail.go
new file mode 100644
index 0000000..c5d189c
--- /dev/null
+++ b/probecmd/detail.go
@@ -0,0 +1,212 @@
+// 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.
+
+package probecmd
+
+import (
+       "fmt"
+       "strings"
+       "unicode/utf8"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// The findings, worded short enough for one line. They follow the web UI's
+// reading of each check's facts, see ProviderAuditCard.tsx.
+var detailPhrases = map[string][2]string{
+       "failed":           {"not asked: %s", "没能发出:%s"},
+       "empty":            {"empty answer", "回答为空"},
+       "wrong":            {"said \"%s\", the answer is %s", "答了“%s”,正确答案是%s"},
+       "forbidden":        {"contains \"%s\", which is ruled out", 
"含有被排除的“%s”"},
+       "right":            {"right", "答对"},
+       "selfUndocumented": {"no known vendor sells this name", "这个模型名不属于已知厂商"},
+       "selfOther":        {"sold as %[2]s's, says it is %[1]s's", 
"卖的是%[2]s的模型,自称出自%[1]s"},
+       "selfSilent":       {"never names %s", "没说自己出自%s"},
+       "selfMatch":        {"says it is %s's", "自称出自%s"},
+       "hiddenFound":      {"a system prompt nobody sent", "有一段不是你发的系统提示"},
+       "hiddenNone":       {"none", "没有"},
+       "paramRejected":    {"refused by the upstream, not scored", 
"被上游拒绝,不计分"},
+       "paramIgnored":     {"accepted, no %s in the answer", "收下了,返回里却没有%s"},
+       "paramShape":       {"%s is %s, want %s", "%s为%s,应为%s"},
+       "paramDropped":     {"accepted, not applied: \"%s\"", "收下了没照做:“%s”"},
+       "paramHonored":     {"honored", "照文档生效"},
+       "repeatModels":     {"different models: %s", "模型名不一致:%s"},
+       "repeatTokens":     {"same input counted as %s", "同样的输入被计为%s"},
+       "repeatAnswers":    {"answers differ, which sampling allows", 
"回答不同,采样允许"},
+       "repeatSame":       {"%s identical answers", "%s次完全一致"},
+       "vendorUndoc":      {"not asked: not a vendor's own host", 
"未发问:不是厂商自己的域名"},
+       "notAsked":         {"not asked", "没能发出"},
+       "noModel":          {"no model name came back", "没有返回模型名"},
+       "identityAlias":    {"%s, a documented alias", "%s,厂商文档写明的别名"},
+       "identityEcho":     {"%s, echoed back and not checkable", 
"%s,原样回显,无法核实"},
+       "identityOk":       {"%s", "%s"},
+       "identityOther":    {"%s answered instead", "实际作答的是%s"},
+       "cacheOk":          {"%s written, %s read back", "写入%s,读回%s"},
+       "cacheWritten":     {"%s written, none read back", "写入%s,没有读回"},
+       "cacheNone":        {"no cache tokens, billed as fresh input", 
"没有缓存token,全按新输入计费"},
+       "billingDrift":     {"same request billed %s and %s", "同一请求计了%s和%s"},
+       "billingEstimate":  {"%[1]s billed for ~%[2]s sent", 
"发出约%[2]s,计了%[1]s"},
+       "streamComplete":   {"every event, in order", "事件齐全且有序"},
+       "streamMissing":    {"missing: %s", "缺失:%s"},
+       "toolsOk":          {"nested schema filled", "嵌套结构完整"},
+       "toolsPartial":     {"nested fields left empty", "嵌套字段没填"},
+       "toolsNone":        {"no tool call", "没有调用工具"},
+       "vendorRelayed":    {"not %s's own host", "不是%s自己的域名"},
+       "vendorNone":       {"no vendor header", "没有厂商响应头"},
+       "vendorFound":      {"%s", "%s"},
+}
+
+func (text *messages) phrase(key string, values ...any) string {
+       index := 0
+       if text == chinese {
+               index = 1
+       }
+       return fmt.Sprintf(detailPhrases[key][index], values...)
+}
+
+func fact(check object.ProbeCheck, index int) string {
+       if index < len(check.Facts) {
+               return check.Facts[index]
+       }
+       return ""
+}
+
+func quote(value string) string {
+       value = strings.Join(strings.Fields(value), " ")
+       if utf8.RuneCountInString(value) > 32 {
+               return string([]rune(value)[:31]) + "…"
+       }
+       return value
+}
+
+func detailOf(check object.ProbeCheck, text *messages) string {
+       switch check.Key {
+       case object.ProbeKnowledge, object.ProbeSelfId, object.ProbeHidden, 
object.ProbeFeature, object.ProbeRepeat:
+               return answerDetailOf(check, text)
+       }
+       if check.Level == object.LlmAuditUnknown && check.Key != 
object.ProbeIdentity {
+               if fact(check, 0) == "undocumented" {
+                       return text.phrase("vendorUndoc")
+               }
+               return text.phrase("notAsked")
+       }
+
+       switch check.Key {
+       case object.ProbeIdentity:
+               switch {
+               case len(check.Facts) == 0:
+                       return text.phrase("noModel")
+               case fact(check, 1) == "alias":
+                       return text.phrase("identityAlias", fact(check, 0))
+               case fact(check, 1) == "unverified":
+                       return text.phrase("identityEcho", fact(check, 0))
+               case check.Level == object.LlmAuditOk:
+                       return text.phrase("identityOk", fact(check, 0))
+               }
+               return text.phrase("identityOther", fact(check, 0))
+       case object.ProbeCache:
+               written, read := fact(check, 0), fact(check, 1)
+               if read != "" && read != "0" {
+                       return text.phrase("cacheOk", written, read)
+               }
+               if written != "" && written != "0" {
+                       return text.phrase("cacheWritten", written)
+               }
+               return text.phrase("cacheNone")
+       case object.ProbeBilling:
+               if fact(check, 0) == "drift" {
+                       return text.phrase("billingDrift", fact(check, 1), 
fact(check, 2))
+               }
+               return text.phrase("billingEstimate", fact(check, 1), 
fact(check, 2))
+       case object.ProbeStream:
+               if len(check.Facts) == 0 {
+                       return text.phrase("streamComplete")
+               }
+               return text.phrase("streamMissing", strings.Join(check.Facts, 
", "))
+       case object.ProbeTools:
+               switch check.Level {
+               case object.LlmAuditOk:
+                       return text.phrase("toolsOk")
+               case object.LlmAuditWarn:
+                       return text.phrase("toolsPartial")
+               }
+               return text.phrase("toolsNone")
+       }
+
+       if fact(check, 0) == "relayed" {
+               return text.phrase("vendorRelayed", fact(check, 1))
+       }
+       if len(check.Facts) == 0 {
+               return text.phrase("vendorNone")
+       }
+       return text.phrase("vendorFound", strings.Join(check.Facts, ", "))
+}
+
+func answerDetailOf(check object.ProbeCheck, text *messages) string {
+       outcome, answer, extra, wanted := fact(check, 0), fact(check, 1), 
fact(check, 2), fact(check, 3)
+       switch outcome {
+       case "failed":
+               return text.phrase("failed", quote(answer))
+       case "empty":
+               return text.phrase("empty")
+       }
+
+       switch check.Key {
+       case object.ProbeKnowledge:
+               switch outcome {
+               case "missed":
+                       return text.phrase("wrong", quote(answer), extra)
+               case "forbidden":
+                       return text.phrase("forbidden", extra)
+               }
+               return text.phrase("right")
+       case object.ProbeSelfId:
+               switch outcome {
+               case "undocumented":
+                       return text.phrase("selfUndocumented")
+               case "other":
+                       return text.phrase("selfOther", extra, wanted)
+               case "silent":
+                       return text.phrase("selfSilent", extra)
+               }
+               return text.phrase("selfMatch", extra)
+       case object.ProbeHidden:
+               if outcome == "hidden" {
+                       return text.phrase("hiddenFound")
+               }
+               return text.phrase("hiddenNone")
+       case object.ProbeFeature:
+               switch outcome {
+               case "rejected":
+                       return text.phrase("paramRejected")
+               case "ignored":
+                       return text.phrase("paramIgnored", answer)
+               case "shape":
+                       return text.phrase("paramShape", answer, extra, wanted)
+               case "dropped":
+                       return text.phrase("paramDropped", quote(answer))
+               }
+               return text.phrase("paramHonored")
+       }
+
+       switch outcome {
+       case "model":
+               return text.phrase("repeatModels", 
strings.Join(check.Facts[1:], ", "))
+       case "tokens":
+               return text.phrase("repeatTokens", 
strings.Join(check.Facts[1:], ", "))
+       case "answers":
+               return text.phrase("repeatAnswers")
+       }
+       return text.phrase("repeatSame", answer)
+}
diff --git a/probecmd/lang_other.go b/probecmd/lang_other.go
new file mode 100644
index 0000000..7e7c963
--- /dev/null
+++ b/probecmd/lang_other.go
@@ -0,0 +1,21 @@
+// 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.
+
+//go:build !windows
+
+package probecmd
+
+func systemLanguage() string {
+       return languageFromEnv()
+}
diff --git a/probecmd/lang_windows.go b/probecmd/lang_windows.go
new file mode 100644
index 0000000..8c2c112
--- /dev/null
+++ b/probecmd/lang_windows.go
@@ -0,0 +1,38 @@
+// 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.
+
+//go:build windows
+
+package probecmd
+
+import "syscall"
+
+// langChinese is the primary language id Windows gives every Chinese locale.
+const langChinese = 0x04
+
+// systemLanguage reads the display language, since Windows sets no LANG.
+func systemLanguage() string {
+       if lang := languageFromEnv(); lang != "" {
+               return lang
+       }
+       proc := 
syscall.NewLazyDLL("kernel32.dll").NewProc("GetUserDefaultUILanguage")
+       if proc.Find() != nil {
+               return ""
+       }
+       id, _, _ := proc.Call()
+       if id&0x3ff == langChinese {
+               return "zh"
+       }
+       return "en"
+}
diff --git a/probecmd/probecmd.go b/probecmd/probecmd.go
new file mode 100644
index 0000000..f48e7da
--- /dev/null
+++ b/probecmd/probecmd.go
@@ -0,0 +1,229 @@
+// 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.
+
+// Package probecmd is "probe": the authenticity suite run once against an
+// endpoint and a key, with no database, no server and nothing installed.
+package probecmd
+
+import (
+       "encoding/json"
+       "flag"
+       "fmt"
+       "io"
+       "net/url"
+       "os"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+// commandName is how the usage line names this program.
+var commandName = "gateway-probe"
+
+// RunCommand handles "probe" and reports whether it did.
+func RunCommand(args []string) bool {
+       if len(args) < 2 || args[1] != "probe" {
+               return false
+       }
+       commandName = "casbin-gateway probe"
+       os.Exit(Run(args[2:], os.Stdout, os.Stderr))
+       return true
+}
+
+type options struct {
+       baseUrl  string
+       key      string
+       model    string
+       protocol string
+       card     string
+       noCard   bool
+       hideHost bool
+       json     bool
+       lang     string
+}
+
+// Run is the whole command, so the standalone binary and the server's
+// subcommand are the same program. It returns the exit code.
+func Run(args []string, stdout io.Writer, stderr io.Writer) int {
+       opts, err := parseArgs(args, stderr)
+       if err == flag.ErrHelp {
+               return 0
+       }
+       if err != nil {
+               fmt.Fprintln(stderr, err)
+               return 2
+       }
+       text := textFor(opts.lang)
+
+       if !opts.json {
+               fmt.Fprintf(stdout, text.starting, displayHost(opts.baseUrl, 
opts.hideHost), opts.protocol)
+       }
+       probe := object.ProbeEndpoint(opts.baseUrl, opts.key, opts.protocol, 
opts.model)
+
+       if opts.json {
+               encoder := json.NewEncoder(stdout)
+               encoder.SetIndent("", "  ")
+               if err := encoder.Encode(probe); err != nil {
+                       fmt.Fprintln(stderr, err)
+                       return 1
+               }
+       } else {
+               printReport(stdout, probe, text)
+       }
+
+       if probe.Grade == object.ProbeGradeUnknown {
+               return 1
+       }
+       if !opts.noCard {
+               path := opts.card
+               if path == "" {
+                       path = defaultCardPath(opts.baseUrl, opts.hideHost)
+               }
+               if err := writeCard(path, probe, displayHost(opts.baseUrl, 
opts.hideHost), opts.protocol); err != nil {
+                       fmt.Fprintln(stderr, err)
+                       return 1
+               }
+               fmt.Fprintf(stderr, text.cardSaved, path)
+       }
+       return 0
+}
+
+func parseArgs(args []string, stderr io.Writer) (*options, error) {
+       opts := &options{}
+       flags := flag.NewFlagSet("probe", flag.ContinueOnError)
+       flags.SetOutput(stderr)
+       flags.StringVar(&opts.key, "key", "", "API key; defaults to 
$GATEWAY_PROBE_KEY, then $ANTHROPIC_API_KEY or $OPENAI_API_KEY")
+       flags.StringVar(&opts.model, "model", "", "model to ask for; defaults 
to the first one the endpoint lists")
+       flags.StringVar(&opts.protocol, "protocol", "", "openai or anthropic; 
guessed from the URL and model when omitted")
+       flags.StringVar(&opts.card, "card", "", "where to save the shareable 
PNG card")
+       flags.BoolVar(&opts.noCard, "no-card", false, "do not save a card")
+       flags.BoolVar(&opts.hideHost, "hide-host", false, "mask the endpoint's 
host on the report and the card")
+       flags.BoolVar(&opts.json, "json", false, "print the full report, 
requests and answers included, as JSON")
+       flags.StringVar(&opts.lang, "lang", "", "en or zh; follows the system 
language when omitted")
+       flags.Usage = func() {
+               fmt.Fprintf(stderr, "usage: %s <base-url> [--key KEY] [--model 
MODEL] [--protocol openai|anthropic] [flags]\n", commandName)
+               fmt.Fprintln(stderr, "")
+               fmt.Fprintln(stderr, "Asks an OpenAI- or Anthropic-compatible 
endpoint questions with checkable answers and grades")
+               fmt.Fprintln(stderr, "whether what answers is the API it claims 
to be. It sends about twenty small requests on your key.")
+               fmt.Fprintln(stderr, "")
+               flags.PrintDefaults()
+       }
+
+       // Flags may come before or after the URL.
+       positional := []string{}
+       for {
+               if err := flags.Parse(args); err != nil {
+                       return nil, err
+               }
+               if flags.NArg() == 0 {
+                       break
+               }
+               positional = append(positional, flags.Arg(0))
+               args = flags.Args()[1:]
+       }
+       if len(positional) != 1 {
+               flags.Usage()
+               return nil, fmt.Errorf("probe needs exactly one base URL")
+       }
+
+       opts.baseUrl = strings.TrimSpace(positional[0])
+       if !strings.Contains(opts.baseUrl, "://") {
+               opts.baseUrl = "https://"; + opts.baseUrl
+       }
+       if parsed, err := url.Parse(opts.baseUrl); err != nil || parsed.Host == 
"" {
+               return nil, fmt.Errorf("%q is not a URL", positional[0])
+       }
+
+       switch strings.ToLower(opts.protocol) {
+       case "":
+               opts.protocol = guessProtocol(opts.baseUrl, opts.model)
+       case "openai":
+               opts.protocol = object.ProtocolOpenAi
+       case "anthropic", "claude":
+               opts.protocol = object.ProtocolAnthropic
+       default:
+               return nil, fmt.Errorf("unknown protocol %q, use openai or 
anthropic", opts.protocol)
+       }
+
+       if opts.key == "" {
+               opts.key = keyFromEnv(opts.protocol)
+       }
+       if opts.key == "" {
+               return nil, fmt.Errorf("no API key: pass --key, or set 
GATEWAY_PROBE_KEY so it stays out of your shell history")
+       }
+       return opts, nil
+}
+
+// guessProtocol picks Anthropic for an endpoint or a model that says so, which
+// is what a relay selling Claude Code access usually serves.
+func guessProtocol(baseUrl string, model string) string {
+       lowered := strings.ToLower(baseUrl)
+       if strings.Contains(lowered, "anthropic") || strings.Contains(lowered, 
"claude") ||
+               strings.HasPrefix(strings.ToLower(model), "claude") {
+               return object.ProtocolAnthropic
+       }
+       return object.ProtocolOpenAi
+}
+
+func keyFromEnv(protocol string) string {
+       names := []string{"GATEWAY_PROBE_KEY"}
+       if protocol == object.ProtocolAnthropic {
+               names = append(names, "ANTHROPIC_API_KEY", 
"ANTHROPIC_AUTH_TOKEN")
+       } else {
+               names = append(names, "OPENAI_API_KEY")
+       }
+       for _, name := range names {
+               if value := strings.TrimSpace(os.Getenv(name)); value != "" {
+                       return value
+               }
+       }
+       return ""
+}
+
+func hostOf(baseUrl string) string {
+       parsed, err := url.Parse(baseUrl)
+       if err != nil {
+               return baseUrl
+       }
+       return parsed.Host
+}
+
+// displayHost is the host as the report shows it. Masked, each label keeps its
+// first letter and the last label stays whole, so a card can be posted without
+// naming the seller.
+func displayHost(baseUrl string, hide bool) string {
+       host := hostOf(baseUrl)
+       if !hide {
+               return host
+       }
+       labels := strings.Split(host, ".")
+       for index, label := range labels {
+               if index == len(labels)-1 && len(labels) > 1 {
+                       break
+               }
+               if len(label) > 1 {
+                       labels[index] = label[:1] + strings.Repeat("*", 
len(label)-1)
+               }
+       }
+       return strings.Join(labels, ".")
+}
+
+func defaultCardPath(baseUrl string, hide bool) string {
+       name := "endpoint"
+       if !hide {
+               name = strings.NewReplacer(":", "-", "*", 
"").Replace(hostOf(baseUrl))
+       }
+       return fmt.Sprintf("probe-%s-%s.png", name, 
time.Now().Format("20060102"))
+}
diff --git a/probecmd/report.go b/probecmd/report.go
new file mode 100644
index 0000000..e516145
--- /dev/null
+++ b/probecmd/report.go
@@ -0,0 +1,129 @@
+// 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.
+
+package probecmd
+
+import (
+       "fmt"
+       "io"
+       "os"
+       "runtime"
+       "strings"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+const (
+       ansiReset  = "\x1b[0m"
+       ansiBold   = "\x1b[1m"
+       ansiDim    = "\x1b[2m"
+       ansiRed    = "\x1b[31m"
+       ansiGreen  = "\x1b[32m"
+       ansiYellow = "\x1b[33m"
+)
+
+// useColor is off for a pipe, for NO_COLOR, and for a Windows console that is
+// not Windows Terminal, which prints the escapes instead of reading them.
+func useColor(out io.Writer) bool {
+       file, ok := out.(*os.File)
+       if !ok || os.Getenv("NO_COLOR") != "" {
+               return false
+       }
+       info, err := file.Stat()
+       if err != nil || info.Mode()&os.ModeCharDevice == 0 {
+               return false
+       }
+       return runtime.GOOS != "windows" || os.Getenv("WT_SESSION") != ""
+}
+
+func printReport(out io.Writer, probe *object.ProviderProbe, text *messages) {
+       color := useColor(out)
+       paint := func(code string, value string) string {
+               if !color {
+                       return value
+               }
+               return code + value + ansiReset
+       }
+
+       if !probe.Ok || probe.Grade == object.ProbeGradeUnknown {
+               reason := probe.Error
+               if reason == "" {
+                       reason = text.nothingMeasured
+               }
+               fmt.Fprintln(out, paint(ansiRed, text.couldNotProbe+reason))
+               return
+       }
+
+       answered := probe.UpstreamModel
+       if answered == "" {
+               answered = "-"
+       }
+       fmt.Fprintf(out, text.models+"\n\n", probe.Model, answered)
+
+       width := 0
+       for _, check := range probe.Checks {
+               width = max(width, textWidth(text.caseTitle(check)))
+       }
+       for _, check := range probe.Checks {
+               title := text.caseTitle(check)
+               padding := strings.Repeat(" ", width-textWidth(title))
+               fmt.Fprintf(out, "  %s  %s%s  %s\n", levelTag(check.Level, 
text, paint), title, padding,
+                       paint(ansiDim, detailOf(check, text)))
+       }
+
+       gradeColor := ansiGreen
+       switch probe.Grade {
+       case object.ProbeGradeC:
+               gradeColor = ansiYellow
+       case object.ProbeGradeD, object.ProbeGradeF:
+               gradeColor = ansiRed
+       }
+       fmt.Fprintln(out)
+       fmt.Fprintln(out, paint(ansiBold+gradeColor, fmt.Sprintf(text.grade, 
probe.Grade, probe.Score, text.gradeMeaning(probe.Grade))))
+
+       cost := text.costUnknown
+       if probe.Priced {
+               cost = fmt.Sprintf("$%.4f", probe.Cost)
+       }
+       fmt.Fprintf(out, text.spent+"\n", probe.Requests, cost, 
float64(probe.DurationMs)/1000)
+       fmt.Fprintln(out, paint(ansiDim, text.evidenceHint))
+}
+
+func levelTag(level string, text *messages, paint func(string, string) string) 
string {
+       switch level {
+       case object.LlmAuditOk:
+               return paint(ansiGreen, text.pass)
+       case object.LlmAuditWarn:
+               return paint(ansiYellow, text.warn)
+       case object.LlmAuditAlert:
+               return paint(ansiRed, text.fail)
+       default:
+               return paint(ansiDim, text.skip)
+       }
+}
+
+// textWidth counts a CJK character as two columns, which is how a terminal
+// draws it.
+func textWidth(value string) int {
+       width := 0
+       for _, r := range value {
+               if r >= 0x1100 && (r <= 0x115f || (r >= 0x2e80 && r <= 0xa4cf) 
|| (r >= 0xac00 && r <= 0xd7a3) ||
+                       (r >= 0xf900 && r <= 0xfaff) || (r >= 0xff00 && r <= 
0xff60) || (r >= 0xffe0 && r <= 0xffe6)) {
+                       width += 2
+               } else {
+                       width++
+               }
+       }
+       return width
+}
diff --git a/probecmd/text.go b/probecmd/text.go
new file mode 100644
index 0000000..ba1d01d
--- /dev/null
+++ b/probecmd/text.go
@@ -0,0 +1,148 @@
+// 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.
+
+package probecmd
+
+import (
+       "os"
+       "strings"
+
+       "github.com/apache/casbin-gateway/object"
+)
+
+type messages struct {
+       starting        string
+       models          string
+       couldNotProbe   string
+       nothingMeasured string
+       grade           string
+       spent           string
+       costUnknown     string
+       evidenceHint    string
+       cardSaved       string
+       pass            string
+       warn            string
+       fail            string
+       skip            string
+       gradeMeanings   map[string]string
+       caseTitles      map[string]string
+}
+
+func (text *messages) gradeMeaning(grade string) string {
+       return text.gradeMeanings[grade]
+}
+
+// caseTitle translates a shipped case by its name. A case the suite does not
+// ship keeps the title it ran under.
+func (text *messages) caseTitle(check object.ProbeCheck) string {
+       if title, ok := text.caseTitles[check.Case]; ok {
+               return title
+       }
+       if check.Title != "" {
+               return check.Title
+       }
+       return check.Key
+}
+
+var english = &messages{
+       starting:        "Probing %s (%s). This sends about twenty small 
requests on your key and can take a minute.\n",
+       models:          "Asked for %s, answered by %s",
+       couldNotProbe:   "Could not probe: ",
+       nothingMeasured: "no check could be measured",
+       grade:           "Grade %s · %.1f/100 · %s",
+       spent:           "%d requests · %s · %.1fs",
+       costUnknown:     "cost unknown",
+       evidenceHint:    "Every request and answer is in --json. Gateway 
re-runs this daily for the providers you add: 
https://github.com/apache/casbin-gateway";,
+       cardSaved:       "Card saved to %s\n",
+       pass:            "PASS",
+       warn:            "WARN",
+       fail:            "FAIL",
+       skip:            "SKIP",
+       gradeMeanings: map[string]string{
+               object.ProbeGradeA: "Answers as documented",
+               object.ProbeGradeB: "Mostly as documented",
+               object.ProbeGradeC: "Several answers do not match",
+               object.ProbeGradeD: "Largely does not match",
+               object.ProbeGradeF: "Does not answer as this API",
+       },
+       caseTitles: map[string]string{},
+}
+
+var chinese = &messages{
+       starting:        "正在检测%s(%s)。会用你的Key发送约20个小请求,最多需要一分钟。\n",
+       models:          "请求的模型:%s,实际作答:%s",
+       couldNotProbe:   "无法检测:",
+       nothingMeasured: "没有任何一项能测出结果",
+       grade:           "等级%s · %.1f/100 · %s",
+       spent:           "%d个请求 · %s · %.1f秒",
+       costUnknown:     "费用未知",
+       evidenceHint:    
"每个请求和返回都在--json里。把中转站加进Gateway,它每天自动复测:https://github.com/apache/casbin-gateway";,
+       cardSaved:       "卡片已保存到%s\n",
+       pass:            "通过",
+       warn:            "存疑",
+       fail:            "不符",
+       skip:            "跳过",
+       gradeMeanings: map[string]string{
+               object.ProbeGradeA: "与官方文档一致",
+               object.ProbeGradeB: "基本一致",
+               object.ProbeGradeC: "多项答案对不上",
+               object.ProbeGradeD: "大部分对不上",
+               object.ProbeGradeF: "不像这套API",
+       },
+       caseTitles: map[string]string{
+               "identity-model-name":          "模型身份",
+               "selfid-vendor":                "自述来历",
+               "hidden-system-prompt":         "隐藏指令",
+               "repeat-same-backend":          "单一后端还是号池",
+               "tools-nested-schema":          "嵌套工具参数",
+               "stream-events-anthropic":      "流式事件(Anthropic)",
+               "stream-events-openai":         "流式事件(OpenAI)",
+               "cache-identical-prefix":       "提示词缓存",
+               "billing-identical-requests":   "Token计费",
+               "vendor-headers":               "厂商响应头",
+               "knowledge-letter-count":       "数字母",
+               "knowledge-decimal-order":      "比较小数大小",
+               "knowledge-arithmetic":         "不借助工具的算术",
+               "knowledge-reverse-string":     "按原序复述",
+               "knowledge-atomic-number":      "长尾知识",
+               "knowledge-day-of-week":        "推算星期",
+               "knowledge-chinese-author":     "中文长尾知识",
+               "knowledge-instruction-exact":  "严格遵循指令",
+               "feature-logprobs":             "Token概率",
+               "feature-choice-count":         "一次返回两个答案",
+               "feature-stop-openai":          "停止序列(OpenAI)",
+               "feature-stop-anthropic":       "停止序列(Anthropic)",
+               "feature-openai-fingerprint":   "后端指纹",
+               "feature-openai-completion-id": "补全ID格式",
+       },
+}
+
+func textFor(lang string) *messages {
+       if lang == "" {
+               lang = systemLanguage()
+       }
+       if strings.HasPrefix(strings.ToLower(lang), "zh") {
+               return chinese
+       }
+       return english
+}
+
+func languageFromEnv() string {
+       for _, name := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
+               if value := os.Getenv(name); value != "" {
+                       return value
+               }
+       }
+       return ""
+}


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

Reply via email to