Metadata-Version: 2.4
Name: relaycheck
Version: 0.1.0
Summary: Audit OpenAI-compatible LLM relay/proxy endpoints for model substitution, billing fraud, and capability degradation.
Author: relaycheck contributors
License-Expression: MIT
Project-URL: Homepage, https://github.com/jayson-yxj/relaycheck
Project-URL: Repository, https://github.com/jayson-yxj/relaycheck
Project-URL: Issues, https://github.com/jayson-yxj/relaycheck/issues
Project-URL: Changelog, https://github.com/jayson-yxj/relaycheck/blob/main/CHANGELOG.md
Keywords: llm,openai,relay,proxy,audit,api,fraud-detection
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Testing
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.31
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Dynamic: license-file

# relaycheck

[![CI](https://github.com/jayson-yxj/relaycheck/actions/workflows/ci.yml/badge.svg)](https://github.com/jayson-yxj/relaycheck/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

检测 LLM API **中转站**（relay / 代理商 / 聚合站）是否**掉包模型**、是否**虚报计费**。

你付的是 `claude-3-5-sonnet` 的钱，拿到的是不是 `deepseek-chat`？你说 `max_tokens=16`，
账单上为什么是 268 个 output token？这个工具用**可复现的证据**回答这两个问题。

> relaycheck 是**只读**的。它只发 chat completions、只读面板自己公开或与你账号相关的端点。
> 它不注册账号、不兑换任何东西、不修改远端状态。

*[English documentation: `README.en.md`](README.en.md)*

---

## 为什么需要它

中转站可以在服务端做这些事，而你从响应里看不出来：

| 它做了什么 | 你看到的 |
|---|---|
| 把你请求的 `model` 字段改写成另一个便宜模型 | 响应里 `model` 字段被改回你请求的名字 |
| 把提示词改写、丢给一个通用后端 | 一个格式正确、内容通顺的回答 |
| 隐藏思维链，但照 output 价格计费 | 你只要一个词，却计费了 350 个 token |
| 接受 `max_tokens` / `stop` / `tools` 然后忽略 | HTTP 200，参数静默消失 |
| 流式返回一个短答案，非流式返回另一个 | 两条路径各自看起来都正常 |
| 长输入只转发最后几千个字符，却按全文计费 | 回答照样通顺，history 却莫名其妙地「记不住」 |

**这些行为都不会留下你能直接读到的痕迹。** 但它们会留下**行为指纹**——同一个后端伪装成
两个模型时，它的 tokenizer 和输出会露出马脚。

---

## 一条硬规则：`CLEAN` 必须意味着「已经验证过」

这是整个工具最重要的一条不变量，比任何单个探针都重要。

报告里的 `CLEAN` 不是「没发现问题」，而是「**这一项查过了，结果是好的**」。
这两者的区别在真实场景里是致命的：

- 中转站在抖动，8 次请求全部超时；
- 如果「拿不到回答」被当成「回答看起来没问题」，报告会给出一个**全绿的结果**——
- 而一个全绿的报告比没有报告更糟：它把一次真实的掉包洗成了清白证明。

所以每个探针都必须区分六种结局，并且只有第一种能写 `CLEAN`：

| 结局 | 报告里长什么样 |
|---|---|
| 检查跑了，结果正常 | `CLEAN`（如 `canary-clean` / `params-clean`） |
| 检查根本没跑成 | `INFO`（`budget-000`，以及 `tok-000` / `echo-000` / `twins-000` / `id-000` / `canary-000` / `bill-000` / `bill-202` / `params-102` / `stream-000` / `ctx-000` / `ctx-102`） |
| 检查跑了，但**没有可判定的内容** | `INFO`（`params-103`：样本为空或过短；`stream-103`：两条路径的回复都是空的） |
| 检查跑了，但**该站不可复现，比对本身无效** | `INFO`（`params-104`：`temperature=0` 两次结果不同；`stream-104`：两次相同请求返回不同，流式比对无意义） |
| 检查跑了，**看到值得记录的异常，但它不构成指控** | `INFO`（`id-100`：模型自述的厂商与售卖名称不符；`echo-101`：响应自称的是同厂商的另一个名字；`id-101`：模型自述不稳定；`id-102`：自述未能复核；`bill-201`：面板公开了上游成本但没加价） |
| 检查跑了，发现异常 | `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` |

> `*-000` 是**保留编号**，专用来表示「这一项没测成」，永远不是一个指控；
> `budget-000` 表示探针被墙钟预算提前掐断，`bill-202` 表示面板端点全不可达。
>
> 第三行是曾经缺掉的那一种。前两种都在说「我没测出来」，
> 第三种却在说「**我测了，但手里那个空字符串什么也证明不了**」——把空字符串
> 当成「不一致」或「参数被忽略」，就是拿自己的预算问题去指控别人的中转站。
> 这一行是踩过坑之后补上的，`reasoning` 场景和它的回归测试专门守着它。
> 看到它们请当成**空白**，不是**合格**。
>
> 第四行是同一个错误的另一种穿法。这次手里不是空字符串，而是**两段真的不一样
> 的文字**——但该站在 `temperature=0` 下自己都不能复现自己（MoE 路由、批处理都会
> 这样），那么「流式与完整回答不同」和「两次调用结果不同」都是**噪声的预期表现**，
> 不是证据。所以规则是：**先量底噪，再下结论**。量不出底噪，就如实写「无法判定」，
> 而不是把噪声写成 HIGH。`noisy` 场景和它的回归测试守着这一条。
>
> 第五行守的是相反方向的错误：**确实看到了点什么，但它撑不起一条指控。**
> 一个模型自称是另一个厂商（`id-100`），这听起来很像「抓到了」，但自述本身就是
> 训练语料喂出来的，非 OpenAI 的模型自称 OpenAI 是常态 —— 它只能当线索记一笔，
> 撑不起一条指控，所以是 `INFO` 而不是 `LOW`。它自己同一句问题问两遍答得都不一样
> （`id-101`）就更不算数了。响应体自称的是同厂商的另一个名字（`echo-101`），
> 或者面板自己公布的上游成本里根本没有加价（`bill-201` 降成 INFO），也都是同一类：
> **值得写进报告，不值得写进指控栏。** 把这些塞进 `MEDIUM`，和把这些直接删掉，
> 是同一个错误的两种穿法——前者冤枉好人，后者让读者以为「没写就是没问题」。

这条规则是**测试守着的**，不是靠自觉：`dead` 场景（面板活着、但每一次 completion
都失败）断言报告里**不允许出现**任何「没查就判清白」的 finding id。
`slow` 场景（行为正确但极慢）断言被截断的探针必须自报「未跑完」，而不是报通过。

> 对使用者的一句话：看到 `INFO` 的 `*-000` / `*-102`，请把它当成**空白**而不是**合格**。
> 你要的是「稳定期重跑一次」，而不是「看起来没问题」。

---

## 检测原理（为什么这些证据站得住）

### 1. Tokenizer 指纹 —— 最便宜、最硬的证据

把 8 个固定测试串（英文 / 中文 / 代码 / emoji / 混合 / 空白 / 数字 / JSON）分别发给每个模型，
每次 `max_tokens=1`，读 `usage.prompt_tokens`。

**两个声称来自不同厂商的模型，如果 8 个串的 token 数全部一致，它们用的是同一个 tokenizer。**
同一个 tokenizer 意味着同一个模型家族——不同厂商不可能共享。

成本：每个模型 9 次请求，每次只输出 1 个 token。

> 诚实边界：相同 tokenizer 只证明**同家族**，不证明**同权重**（`gpt-4o` 和 `gpt-4o-mini`
> 共享 tokenizer）。所以探针会先看这些名字**自称**是哪个厂商：
>
> - 名字互相矛盾（`claude-3-5-sonnet` 和 `deepseek-chat` 却共享 tokenizer）→ `tok-100` **MEDIUM**；
> - 名字本来就同厂商（`gpt-4o` 和 `gpt-4o-mini`）→ `tok-101` **INFO**，只作为事实记录，
>   明确写着「这**不是**掉包证据」。
>
> 一条规则：**只有互相矛盾的名称才构成证据**。名称解析不出厂商时按「未知」处理，
> 绝不把「未知」读成「冲突」。

### 2. 响应体自报的模型名 —— 一条几乎白送的硬证据

每个兼容 OpenAI 协议的网关都**必须**在回复里填 `model` 字段。一个把请求真的转发到
另一个后端的站，通常会把后端的名字原样带出来 —— 改写它属于额外的工作，而这个字段
又不是客户端会检查的东西。

于是「你付钱买的模型名」和「上游自称的模型名」可以摆进同一条证据，代价是每个模型
**两次请求**（完整返回一次、流式返回一次，因为那是站方要分别填写的两个位置）。

这条检查不把那个字段当口供：

| 情况 | 判定 |
|---|---|
| 与请求名完全一致 | `echo-clean` |
| 名字不同但同厂商（`gpt-4o` → `gpt-4o-2024-11-20`） | `echo-101` **INFO**，明写「这**不是**指控」 |
| 名字不同且跨厂商（请求 `deepseek-chat`，响应自称 `claude-3-5-sonnet`） | `echo-100` **MEDIUM / 疑似** |
| 字段为空，或名字看不出属于哪家厂商 | `echo-102` **INFO**：无法对照 |

> 诚实边界：字段由上游填写，所以它可能是站方伪造的、被统一改写成售卖名的，也可能是空的。
> 跨厂商冲突**通常**意味着请求真的落在了另一个后端上，但也可能是站方套了一层自己的
> 上游命名。所以 `echo-100` 的信度是「疑似」而不是「已确认」，措辞里也请你把它和
> `twins`、`tokenizer` 一起读。
>
> 反方向同样重要：只有每个模型在**两条路径上都**拿到了能对照的响应，才会输出
> `echo-clean`。有请求失败、或预算提前用完，一律是 `echo-000` INFO ——
> 「没测成」永远不许写成「没问题」。

### 3. 双胞胎比对 —— 掉包的直接证据

把同一组**开放式**提示词发给每个模型（`temperature=0`），两两比对输出。

关键设计：**提示词必须是开放式的**。
早期版本用了「写出 1 到 20」「计算 17×23」这类**唯一正确答案**的提示词——这是个陷阱：
两个**真的不同**的模型都会输出 `1 2 3 ... 20`，于是每个诚实的中转站都会被判成骗子。
只有当答案空间足够大时，「逐字节一致」才是有意义的证据。

所以用的是：虚构的协议名、关于丢包的俳句、不存在的颜色、想象中的城市。

每个提示词对同一个模型**跑两次**。只有两次逐字一致的提示词才进入比对——如果后端本身不可复现，
跨模型的差异就什么都证明不了，跨模型的一致也只是运气。不可复现的提示词会被丢弃并**如实
记录为局限**，而不是悄悄产生一个假阴性。

严重程度阶梯：

| 情况 | 判定 |
|---|---|
| tokenizer 相同 + 输出一致 + 声称不同厂商 | **CRITICAL**（已确认） |
| 声称不同厂商 + 输出一致 | HIGH |
| tokenizer 相同 + 输出一致 + **声称同一厂商** | LOW（`twins-101`，不下结论） |
| 输出一致（厂商未知） | MEDIUM（疑似） |

最后那行 LOW 是刻意压低的。同一厂商的两个名字输出逐字一致，可能是**合法的别名**
（同一套权重挂在两个名字下，例如 `gpt-4o` 与 `chatgpt-4o-latest`），也可能是把一个后端
当成两个档位卖给你。**单独的输出比对区分不了这两种情况**，所以它报 LOW 并且不下结论——
LOW 不触发默认的 `--fail-on high`，不会把一个诚实的别名对判成骗子。

### 4. Canary 注入 —— 提示词有没有原样送达

在提示词里塞一个唯一标记 `RC-XXXXXXXXXXXX`，要求模型复述。正常转发的中转站会照做。
不回显说明提示词被中间层改写，或者返回的是预置/缓存答案。

### 5. 隐藏思维链计费

要一个词（`max_tokens` 很小），看 `completion_tokens` 与可见字符数是否严重不成比例。
如果响应里既没有 `reasoning_content` 字段、也没有上报 `reasoning_tokens`，但 completion 仍然很大，
那就是思维链被隐藏了、却照 output 价格收费。

### 6. 参数透传

每一项都是**行为测试**，不信 HTTP 200：

- `max_tokens`：要 16，账单却 > 24 → 忽略
- `stop`：差分测试——先拿基线（含 stop token），再带 stop 请求。生效则被截断，被忽略则返回完全一样
- `temperature`：两阶段——`temperature=0` 必须可复现，`temperature=1.5` 两次必须不同（相同说明采样被丢弃）
- `n=2` / `response_format` / `logprobs` / `tools`：看返回里到底有没有

> `temperature` 这一项有个反直觉的地方：**`temperature=0` 不可复现不等于参数被丢弃**。
> MoE 路由、批处理的后端本身就不确定，诚实站也会这样。所以「两次结果不同」
> 单列为 `params-104`（INFO，**明确写着不指控参数被忽略**），
> `params-100`（MEDIUM）只留给「有可判定样本、且样本之间矛盾」的情况。
> 分不清就不指控——这是这个项目最基本的一条纪律。

### 7. 流式完整性

同一提示词，流式与非流式**用完全相同的采样参数**各跑一次，并且把非流式那一次**再跑一遍作对照**。
三件事按顺序判定：

1. 两次非流式请求如果自己就不一样 → 该站没有可复现输出，开放式比对**作废**（不能拿噪声当掉包证据）；
   此时改用**确定性问答**回退（`37 * 41`，只有一个正确答案，采样噪声解释不了差异），
   仍是一致 → `stream-104`（INFO）：没测成，但如实报告；真有分歧 → `stream-100`（HIGH）。
2. 两条路径内容不一致 → `stream-100`（HIGH），两条路径不是同一个后端或计费口径不同。
3. 流式不返回 usage → `stream-101`（MEDIUM），你无法对账。

> 第一版这里犯过错：流式路径没传 `temperature`，非流式传了 `temperature=0`，
> 然后把「两次不同参数的输出不同」当成掉包，发了 HIGH。改了参数对齐还不够，
> 于是又补了上面第 1 步的对照与回退。

### 8. 面板自报口径

读 `/v1/sub2api/billing`、`/v1/usage`、`/api/v1/settings/public` 等端点。
如果 `/v1/usage` 同时给出 `cost`（向你收的）和 `account_cost`（它自己记的上游成本），
加价倍率就不再是猜测，而是**平台自己的账**。

> **商家口头说「按请求数计费」时**：面板 `/v1/sub2api/billing` 的 `billing_scope` 是权威值。
> `billing_scope=token` 就说明按 token 计费，口头说法是假的。

### 9. 可用性 —— 先确认对方到底能不能干活

这条不是掉包检测，但没有它，上面所有结论都可能站不住。

探针发 8 次最简请求（`max_tokens=8`），**关闭重试**统计原始成功率与延迟分位数。
关闭重试是刻意的：开了重试就看不到真实的失败率，而那正是要测的东西。

- 失败率 > 20% → `HIGH`。一个连一句话都服务不好的中转站，
  既不能稳定干活，也让其他探针的结论随时可能不完整。
- 中位延迟 > 15 秒 → `MEDIUM`。通常意味着共享池排队或被限速，而不是直连官方。

结果会同时写进报告的备注里，所以读报告的人第一眼就知道：
**后面那些「没发现问题」到底是真的没问题，还是压根没跑完。**

### 10. 上下文完整性 —— 长输入有没有被悄悄砍掉

这条针对一种很不显眼、但很花钱的行为：中转站宣称支持 128k 上下文，
实际只把**最后几千个字符**转发给后端，前面的全丢了，然后照你发送的完整长度计费。

它比掉包难发现得多，因为**没有任何异常信号**：不报错、不警告、HTTP 200、
回答通顺、usage 里的 `prompt_tokens` 看着也合理。你只会觉得「这模型记性不好」。

做法是「两标记针法」：

1. 在一个长输入的**文首**埋一个标记 `ALPHA-<12 位随机码>`，**文末**埋一个 `BETA-<12 位随机码>`，
   中间填满无意义文本，再要求模型按顺序回显这两个标记。
2. 从浅到深逐级加大输入（默认 2000 → 8000 → 32000 tokens），一旦发现截断立即停止，不再往上烧钱。

判读方式是差分，不依赖模型的记忆力：

| 观察到 | 结论 |
|---|---|
| 两个标记都取回 | 该深度以内没有被截断 |
| 只有文末标记 | **头部被丢弃**（最常见的截断方式） |
| 只有文首标记 | 尾部被丢弃 |
| 两个都没有 | 当作**不可判读**，不算证据 |

两个标记都是随机生成的十二位字符串，模型不可能凭空猜出其中一个却漏掉另一个，
所以「只回显文末」这个结果只有一种解释。

- 有更浅一档完整通过作为对照 → `HIGH` / 很可能
- 最深一档就丢标记、没有对照 → `MEDIUM` / 疑似
- 明确被拒绝（413 / 414 / 422，或 400 且正文提到 token 上限）→ `INFO`。
  这是**如实告知**，不是欺诈；很多服务就是这么做的。

> 诚实的中转站也会在某个深度之后开始丢内容——那是它自己后端的上限。
> 所以 `ctx-clean` 的措辞是「**在测试范围内**完整到达」，并明确写出
> 「这只是下限，不代表该站承诺的上限一定成立」。探针不会把它读成「支持 128k」。

这个探针每档深度都要发一次几千到几万 token 的请求，**很贵**，
因此默认不启用（属于 `--probes all`），且默认只测 1 个模型。

---

## 安装

```bash
# 直接从 GitHub 装，不用先 clone
pip install "git+https://github.com/jayson-yxj/relaycheck.git"

# 或者从源码目录装，会建出 relaycheck 这个命令
pip install .

# 开发模式（带 pytest）
pip install -e ".[dev]"

# 或者干脆不装，直接跑
python -m relaycheck.cli --help
```

依赖只有 `requests`。Python ≥ 3.9。

关于版本：语法用 `ast.parse(..., feature_version=(3, 9))` 逐文件核对过（20 个文件，0 个不兼容），
端到端只有 3.11 上实测过。CI 配了 3.9 / 3.11 / 3.13 三条 Linux 腿加 Windows、macOS 各一条，
但**那套配置还没在真 CI 上跑过**——第一次 push 之后才知道它是不是真的绿。

---

## 用法

```bash
# 最简：自动发现模型，跑默认探针
relaycheck --base-url https://api.example.com --api-key sk-xxxx

# 指定要对比的模型（双胞胎检测最有价值：尽量选声称来自不同厂商的）
relaycheck -u https://api.example.com -k sk-xxxx \
    --models "gpt-4o,claude-3-5-sonnet,deepseek-chat,gemini-1.5-pro"

# 全量探针（含参数透传、流式完整性与长输入截断）
relaycheck -u https://api.example.com -k sk-xxxx --probes all

# 只跑最便宜、最硬的三个探针
relaycheck -u https://api.example.com -k sk-xxxx --probes echo,tokenizer,twins

# 只查长输入有没有被悄悄砍掉（每次请求都很贵，按需使用）
relaycheck -u https://api.example.com -k sk-xxxx --probes context \
    --context-sizes 8000,32000,128000

# 对方特别慢？先只测可用性，确认它到底能不能服务
relaycheck -u https://api.example.com -k sk-xxxx --probes reliability
```

Key 也可以走环境变量：`RELAYCHECK_API_KEY` / `OPENAI_API_KEY`。

### 常用参数

| 参数 | 说明 |
|---|---|
| `-m, --models` | 受测模型，逗号分隔。缺省时从 `/v1/models` 里按**厂商多样性**挑选 |
| `--max-models` | 最多测几个（默认 6，控制请求数与花费） |
| `--probes` | 探针子集或 `all` |
| `--delay` | 请求间隔秒数（默认 0.4，避免触发限流） |
| `--timeout` | 单次请求超时秒数（默认 60） |
| `--budget` | **每个探针**的墙钟预算秒数（默认 240）。见下节 |
| `--reliability-samples` | 可用性探针采样次数（默认 8） |
| `--context-sizes` | 上下文探针的测试深度（token，默认 `2000,8000,32000`），逐级加深，发现截断即停 |
| `--context-max-models` | 上下文探针最多测几个模型（默认 1；该探针很贵） |
| `--fail-on` | 达到该级别返回退出码 1（默认 high） |
| `--list-probes` / `--list-models` | 只看清单 |

### 关于 `--budget`：慢站点不会被挂死

真实世界里大量中转站是**又慢又不稳**的。作者实测过一家：最简请求（只要求回一个词、
输出上限 8 token）连发 9 次，5 次在 45 秒内没有返回；同一模型一会儿 1.9 秒、一会儿
45 秒超时。没有预算的话，`tokenizer` 探针那 27 个请求会跑几十分钟，看起来就像卡死。

所以每个探针都有独立的墙钟预算（默认 240 秒）。预算用尽时：

- 探针**立即停止**，客户端在每次请求前和请求超时上都受同一个截止时间约束；
- 报告里**明确写出**「该探针因超时预算提前结束，未完成的部分没有结论」，
  并列出完成了多少；

**重点**：被截断的探针绝不会显示为「通过」。宁可报告不完整，也不给一个假清白。
想跑得更完整就调大 `--budget`（`--budget 0` 表示不限），但请先确认对方值不值得等。

慢站点跑的时候你会看到心跳行，用来区分「慢」和「卡死」：

```
  → reliability …
      · DeepSeek v4 Flash 第 1/8 次探测中…
      · DeepSeek v4 Flash 第 2/8 次探测中…
    ✓ reliability: high (8 请求 / 241.3s) [预算用尽，未跑完]
      成功率 3/8（失败率 62%），中位延迟 2.9s
```

### 输出

- `report.md` —— 可读报告，**可以直接作为投诉附件**
- `report.json` —— 全部原始数据，任何结论都能自行复核

退出码：`0` 无问题 / `1` 达到 `--fail-on` / `2` 运行失败。

这三个码必须互不重合，所以代码里有一条硬约束：**任何异常都不许以退出码 1 收场。**
一次崩溃和一条真实指控如果都返回 1，从 shell 里看就没法区分了 —— 脚本会把它当成
「查出来了」。同理，进度行里的字符编不出来也不许中断审计：Windows 控制台默认是 GBK，
`✓` 这类字符编不出，旧版本会在这里抛 `UnicodeEncodeError`，审计在探针循环中间死掉、
报告一个字都没写、退出码还是 1。现在只放宽 `errors`（不放宽 `encoding`），编不出的字符
退化成 `?`，中文在 cmd.exe 里照旧正常显示。

`report.md` / `report.json` 一律是 UTF-8，与控制台代码页无关。想让 stdout 也变 UTF-8
（比如重定向进日志再拿 UTF-8 工具读），设环境变量 `PYTHONUTF8=1` 即可。

换行一律是 `\n`，Windows 上也是。否则同一份审计在两台机器上产出的报告会有一个整文件的
差异（每行都「变了」），真正变了的那个字段反而看不出来。

---

## 报告怎么用

每条 finding 都带**严重程度**和**可信度**两个独立字段。这两件事必须分开：
「这很糟」和「我们能确定这很糟」是不同的主张。

- `CRITICAL` + `已确认` = 可以直接拿去对质的证据
- `HIGH` + `很可能` = 强推断，需要商家提供日志才能彻底定论
- `MEDIUM` + `疑似` = 值得人看一眼的信号，**不要**单独拿它去指控

对质时的说法（以 `twins-100` 为例）：

> 你们的 `claude-3-5-sonnet` 和 `deepseek-chat` 在 8 个固定测试串上返回完全一致的
> `prompt_tokens`，并且在 4 个开放性提示词上、每个提示词各采样 2 次、输出全部逐字节一致。
> 请出示这两个模型各自的上游调用凭据与账单。

---

## 诚实的局限

这个工具的结论有明确边界。**用之前务必读这一段**，否则会做出自己无法支撑的指控。

1. **相同 tokenizer ≠ 相同权重。** 只证明同家族。必须配合行为比对。
2. **行为一致可能是缓存，不一定是掉包。** canary 探针就是用来区分这两种情况的。
3. **模型自述不可靠。** 模型会幻觉自己的身份，非 OpenAI 的模型自称 OpenAI 是常见现象
   （训练语料所致）——**诚实站上照样会出现**：实测中一个正常转售 `deepseek-v4-flash`
   的站，该模型两轮都稳定地自称 OpenAI。所以 `id-100` 只是 `INFO` 的 `疑似`，标题里写着
   「线索，非结论」：它只负责记下「这个模型说自己是谁」，结论由 `tokenizer` 和 `twins` 出。
   它一路从 MEDIUM 降到 LOW、再降到 INFO，理由都是同一条：**撑不起指控的东西不该出现在
   指控栏里**，否则每一个转售 DeepSeek 的诚实站都会挨一条 LOW。
   另外还有一道门槛：**自述只有被重复一遍才算线索。** 问到某个外国厂商时，探针会把同一个
   问题再问一次；两次都点名同一个外国厂商才输出 `id-100`，答得不一致就降级成
   `id-101`（INFO，措辞里明确写着**不指控掉包**）。
4. **参数被忽略可能只是兼容层缺陷**，不必然是恶意。报告中已按此区分（`params-100` MEDIUM /
   `params-101` INFO）。
5. **部分模型可能不支持某些能力**（如 `logprobs`、`tools`）。探针会把「明确报错」与
   「静默忽略」分开记录——前者是兼容性缺口，后者才是欺骗。
6. **请求会消耗你的额度。** 全量探针在 6 个模型上约 210 次请求。先用
   `--probes echo,tokenizer,twins` 做初筛——`echo` 每个模型只花两次请求，
   是最便宜的硬检查。
7. **限流可能使部分探针无法完成。** 探针会如实报告「未能执行」，不会把崩溃当成通过。
8. **推理模型会先烧隐藏推理，再写正文。** `max_tokens` 给得太小的时候，返回的可见正文是
   空的、`finish_reason` 是 `length`、`reasoning_content` 里却写了一大段。
   **空回复不是证据**：探针会先按放大后的预算重试一次，再去比较内容；确实拿不到内容的
   检查一律记成「未检验」（`params-103` / `stream-103` / `*-000`），绝不写成「参数被忽略」
   或「两条路径不一致」。这一条是踩过坑之后补上的——在一个真实且诚实的中转站上，
   这个形状曾经让本工具误报出一条 HIGH 和一条 MEDIUM。
9. **不可复现的站不能用「比对」来指控。** 有些后端（MoE 路由、批处理）在
   `temperature=0` 下本身就不确定，同一个请求两次返回不同的文字。此时
   「流式与完整回答不同」和「两次调用结果不同」都是**噪声的预期表现**。
   探针会先量底噪：量不出来就退回确定性问答，仍判定不了就写
   `stream-104` / `params-104`（都是 INFO，措辞里明确写着**不指控**）。
   把这种噪声写成 HIGH 或 MEDIUM，是同一个错误的两种穿法。
   这一条同样来自那个真实诚实站——修完推理模型的空回复之后，紧接着踩到的就是它。
10. **`ctx-clean` 只是一个下限。** 它只证明「在本次测试到的最深一档以内，输入完整到达」，
   不证明该站宣称的上下文长度成立。想看更深的深度就调大 `--context-sizes`——
   代价是每次请求都更长、更贵。
11. **响应体里的 `model` 字段不是口供。** 它是站方填的：可以伪造，可以被统一改写成售卖名，
    也可以是空的。所以跨厂商冲突只能算 `疑似`（`echo-100`），而且它和 `twins`、
    `tokenizer` 是三种互相独立的观察，请一起读。这个探针真正的价值在反方向：
    扣掉它之后，只有**两条路径都**拿到可对照响应的模型才会输出 `echo-clean`，
    拿不到就是 `echo-000` INFO。

---

## 开发

```bash
# 自测：本地模拟中转站，八个场景
python tests/test_mock_relay.py
```

`tests/mock_relay.py` 起八个本地服务：

- `fraudulent` —— 4 个模型名由 2 个后端提供，跨厂商别名、参数全部忽略、流式返回不一致、
  隐藏思维链计费、**长输入被砍到只剩最后 2 万个字符**（约 5k token，正好落在上下文探针
  默认前两档之间，所以能看到「浅的一档通过、深的一档丢标记」这个明确的形状）
- `clean` —— 3 个真实不同的 tokenizer、诚实自述、精确计费、参数全部生效、长输入完整转发
- `same-vendor` —— 同一厂商的两个名字挂在一个后端上（合法别名的情形），其余一切诚实
- `slow` —— 行为完全正确，但每次请求慢 2~3 秒（用来验证探针时间预算）
- `dead` —— 面板和模型列表正常，但**每一次 completion 都失败**（用来验证上一条硬规则）
- `reasoning` —— **推理模型后端**：小 `max_tokens` 时可见正文为空、`reasoning_content`
  有内容、`finish_reason` 是 `length`，计费如实包含推理 token。其余一切诚实。
  这个场景守的是第三种错误，也正是真实站点上踩到的那一种：**两个空字符串不能被读成
  「两条路径不一致」，空样本也不能被读成「参数被忽略」**
- `noisy` —— **诚实但输出不可复现**：`temperature=0` 下同一个请求连发两次会得到两段不同的
  文字，其余一切诚实。守的是第四种错误：**噪声不是证据**；而且探针不许在这里沉默——
  该站既然自己都不可复现，报告就必须把这件事说出来（`stream-104` / `params-104`，都是 INFO）
- `unstable-self` —— **模型每次自述都换一个厂商**（同一句问题问两遍得到不同答案），
  其余一切诚实。守的是第五种错误：**一条连自己都重复不了的自述不构成线索**，
  必须降级为 `id-101`（INFO），而不是发一条 `id-100`

**验收标准是两半**：`fraudulent` 必须被抓出来（≥1 条 CRITICAL，且是具体的 finding id），
`clean`、`same-vendor`、`slow`、`dead`、`reasoning`、`noisy`、`unstable-self` 必须**零误报**
（`same-vendor` 允许一条 LOW 别名提示）。
一个对诚实中转站乱叫的工具比没有工具更糟——它会把一次真实的指控洗成噪音。
「慢」不等于「有问题」，自己的超时更不能变成对别人的指控，我们的 token 预算也不等于
对方的参数有问题。

「零误报」不是口号，是回归测试：

- `test_same_vendor_aliases_are_not_accused` 断言 `same-vendor` 场景下没有 MEDIUM 及以上的
  finding、`tok-100` 和 `twins-100` 都不出现，而 `tok-101` 和 `twins-101` 出现。
  任何把「同厂商共享 tokenizer」重新升级成指控的改动都会当场挂掉。
- `test_dead_relay_is_never_reported_as_clean` 断言 `dead` 场景下不允许出现
  `id-clean` / `canary-clean` / `stream-clean` / `params-clean` / `twins-clean` / `ctx-clean`
  中的任何一个，并且必须出现 `id-000` / `canary-000` / `stream-000` / `ctx-000` / `rel-100`。
  任何把「没查成」重新写成「查过了没问题」的改动都会当场挂掉。
- `test_context_probe_catches_silent_truncation` 断言 `fraudulent` 场景下
  `ctx-100` 报到 `HIGH`（因为有更浅的对照深度）、`missing == "head"`、且证据里带着服务端
  回报的 `prompt_tokens`；同时断言 `clean` 场景下真的出现 `ctx-clean`——
  否则这个探针可能腐烂成永远只发 `ctx-000`，而所有「干净」的报告都会在这里变得毫无意义。
- `test_reasoning_relay_is_not_falsely_accused` 断言 `reasoning` 场景下**没有任何
  CLEAN 以上的 finding**，特别是 `stream-100` 和 `params-100` 都不出现；同时断言
  `twins-clean` / `id-clean` / `canary-clean` 真的出现——光「不误报」不够，
  放大预算后探针必须真的得出结论，而不是集体退化成一堆 `*-000`。
  这个场景来自一次真实误报：诚实站的两个推理模型，让小预算的探针把空回复当成了证据。
- `test_noisy_relay_is_not_falsely_accused` 断言 `noisy` 场景下**没有任何 CLEAN 以上的
  finding**，特别是 `stream-100` 和 `params-100` 都不出现；同时断言 `stream-104` 和
  `params-104` **真的出现**（这里沉默也是一种撒谎：该站自己都不可复现，报告必须说出来），
  并且 `stream-clean` **不出现**——开放式比对已经被弃用，宣称「流式与完整返回一致」等于
  把一次没有真正执行的比对写成了通过。
- `test_echo_probe_compares_the_reported_model_name` 断言 `fraudulent` 场景只跑 `echo`
  也会得到 `echo-100`（MEDIUM/疑似），且证据里点名的正是那两个跨厂商别名、完整与流式
  **两条路径都**被抓到；`clean` 只得到 `echo-clean`；`same-vendor` 得到 `echo-101` 而没有
  `echo-100`。任何把「同一个后端挂了两个名字」重新升级成跨厂商指控的改动都会当场挂掉。
- `test_unstable_self_report_is_not_an_accusation` 断言 `unstable-self` 场景下**没有任何
  CLEAN 以上的 finding**、`id-100` 不出现、`id-101` 出现、`id-clean` 也不出现——
  最后这一条同样是防沉默：自述既然不稳定，报告不能一边说「未发现矛盾」。
- `test_cli_survives_a_legacy_console_encoding` 在子进程里把 stdout 钉到 cp936（中国区
  Windows 的默认代码页）跑一遍真实 CLI，断言它正常收尾、写出了报告、退出码为 0。
  这条守的是另一类混淆：进度行里有个 cp936 编不出来的字符（`✓`），它会在探针循环中间抛
  `UnicodeEncodeError`，于是**一次崩溃以退出码 1 收场，和「发现了高于阈值的问题」撞在一起**。
  从 shell 里看，一个排版 bug 和一条真实指控长得一模一样。
- `test_model_autodiscovery_runs_without_models_flag` 断言**不带 `--models`** 时（第一次用的人
  走的就是这条路）模型真的是从 `/v1/models` 按厂商多样性挑出来的：mock 摆出 3 个不同厂商的
  模型，`--max-models 2` 必须各取一个，而不是照目录顺序取前两个。
  这条守的是一种**沉默的**失败：挑错了模型不会报错，探针会拿同一个后端跟自己比，然后报告
  说这家站很干净。选择错误是唯一一类「没有任何可见症状」的错误。

```bash
python tests/mock_relay.py --port 8123 --scenario fraudulent --verbose
```

### 项目结构

```
relaycheck/
  client.py        HTTP 客户端（重试、退避、截止时间、SSE 解析、base_url 归一）
  models.py        Finding / Severity / Confidence / Usage / Completion
  families.py      厂商关键词 → 家族判定（selection / echo / tokenizer / identity 共用一份）
  selection.py     从 /v1/models 按厂商多样性挑模型
  reporter.py      文本 / Markdown / JSON 报告
  cli.py           命令行入口
  probes/
    base.py        探针框架（崩溃隔离 + 墙钟预算 + 进度心跳）
    reliability.py 可用性：成功率 / 延迟分位数（关重试测原始值）
    echo.py        响应体自报的模型名（完整返回 + 流式返回各一次）
    tokenizer.py   tokenizer 指纹
    twins.py       双胞胎行为比对
    identity.py    身份自述 + canary 注入
    billing.py     隐藏思维链计费 + 面板口径
    params.py      参数透传（7 项行为测试）
    stream.py      流式完整性
    context.py     长输入是否被静默截断（文首/文末双标记 + 升序阶梯）
tests/
  mock_relay.py        模拟中转站（八个场景）
  test_mock_relay.py   端到端验收（15 项）
  test_selection.py    模型选择单元测试（15 项，不联网、不起服务）
examples/
  report-*.md          四份真实工具输出（掉包 / 诚实 / 死站 / 不可复现）
.github/workflows/
  ci.yml               3.9 / 3.11 / 3.13 × Linux，外加 Windows 与 macOS 各一条腿
  release.yml          打 tag 时经 Trusted Publishing 发到 PyPI（仓库里不存任何凭据）
SECURITY.md            安全边界、报告里有什么、怎么报漏洞
CHANGELOG.md           行为变更，尤其是 finding id 与严重程度的语义变化
RELEASING.md           给维护者看：一次性配置与发布步骤
```

> `families.py` 单独成文件是有原因的：`selection.py`（挑跨厂商模型）、`tokenizer.py`
> （判断共享 tokenizer 是否可疑）、`identity.py`（比对自述）都必须对「这个名字属于哪个厂商」
> 给出**同一个答案**。三份各自维护的关键词表迟早会漂移，然后同一个模型会在报告里被两个探针
> 判成不同厂商——那是最难查的一类假指控。

新增探针：继承 `probes/base.py` 的 `Probe`，实现 `run(ctx)`，在 `probes/__init__.py`
的 `ALL_PROBES` 里注册。**如果你的探针会循环请求，请务必在循环里检查
`ctx.out_of_budget()`、捕获 `RelayBudgetExceeded` 并调用 `self._note_budget(...)`** ——
否则一个慢中转站就能把这个探针变成永不停机的黑盒。
`RelayBudgetExceeded` 必须**单独**捕获，不能掉进泛化的 `except Exception`：那会把
「我们主动停了」变成「中转站报错了」，也就是一次由我们自己的超时制造的假指控。

---

## 安全

用它之前请读一遍 [`SECURITY.md`](SECURITY.md)，三条要点：

- **只用你自己的 Key，打你自己在用的站。** 这个工具只发只读的对话请求，不发消息、
  不改配置、不动计费接口的写操作。唯一的副作用是**这些请求会真实计费到你的账户上**。
- **报告里不会出现你的 Key。** `relaycheck/reporter.py` 全文不引用 `api_key`，
  只写目标 URL。但报告里有**你账户**的余额、消费与用量——那是敏感信息，
  公开分享前先删掉「面板」「计费」段落。
- **输出的是证据，不是判决。** `severity`（多严重）和 `confidence`（多确定）刻意分开；
  `LOW` / `SUSPECTED` 的意思是「有线索，撑不起指控」。
  拿报告去理论之前，先读「诚实的局限」和每条发现的「建议」。

---

## 许可

MIT
