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]
