Files
ModelRouter/internal/lua/plugin_icons_test.go
JianFeeeee dcf327d3c5 fix(plugins): 三个坏渠道 + 三插件图标 + 计费可见性
用户报障:「检查一下,同时修复坏渠道,还有插件不是可以自己决定注册
哪些渠道吗?你怎么不修一下,还有除了 billing 以外的两个插件都没有正确的
图标,而且另外两个插件计费也不正确」

## 一、坏渠道(都在插件里修,不动内核)

### 1. zcode —— base_url 吞掉了 endpoint 路径(404)
`base_url = https://zcode.z.ai/api/v1/zcode-plan/anthropic`
`endpoint = /api/v1/zcode-plan/anthropic/v1/messages`
内核 URL = base_url + endpoint(provider.go ChatURL)⇒ 路径拼两遍:
`.../anthropic/api/v1/zcode-plan/anthropic/v1/messages` → 404 page not found。
修:base_url 只留 host,完整路径进 endpoint。与 v2.0.30
「12 平台 base_url 侵占 endpoint」同族。

### 2. minimax —— 打到 Web 前端(404「简单指令,无限可能」)
base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions,那是 Web 前端。
照参考 minimax-product.ts:
  MINIMAX_INFER_PATH = /mavis/api/v1/llm/v1/messages(**Anthropic Messages**)
实测(2026-10-19):
  /mavis/api/v1/llm/v1/messages   → 401 invalid access token(路径对)
  裸 /chat/completions            → 404 page not found
  /mavis/api/v1/chat/completions  → 404
修:endpoint 指向真实推理路径 + adapter 改 anthropic(第二层:协议形状)。
修后从 404 变 401(凭据问题,不再是路由问题)。

### 3. codearts —— 只声明"可续期"却没声明"怎么续"
`account_refresh` 走 `refreshCredential()`,要求 `plat.refresh.url` 或
`refresh_fn` 存在。codearts 只有 accountView 里硬编码的 `refreshable=true`
⇒ 恒返回「该平台未声明续期方式」,自动续期链空转,日志反复
`模型刷新失败 HTTP 400`。而 STS security_token 只有 1 小时寿命,过期后
每个请求都是 `APIG.0602: the security token has expired`。

修法(三层,每层都有独立的坑):
a) 加 `refresh_fn` 钩子。华为云 STS 要 **DPoP 头**(jwk 在凭据里),
   声明式 REFRESH_STYLES 拿不到私钥 ⇒ 必须用钩子挂已有的
   `codeartsRequestToken`。
b) **JWK → PEM 重建**。`codeartsNewDpop()` 返回 {pem,public,private},但
   凭据只持久化了 private(JWK),pem 是登录临时文件。而 `codeartsDpopSign`
   走 openssl 命令行要 `key.pem` 字符串 ⇒ 直接传会报
   `bad argument #1 to 'write' (string expected, got nil)`。
   手工组 SEC1 `EC PRIVATE KEY` 的 DER(INTEGER 1 / OCTET STRING d /
   [0] OID prime256v1 / [1] BIT STRING 04||X||Y)再 PEM 包装。
c) 绑定放在**文件末尾的绑定循环**(与 claim_fn 同处)。在 PLATFORMS 表里
   内联会闭包捕获当时的全局 nil —— 与 trae headers_fn 那次同一坑。

## 二、插件图标(3 个坏 1 个)

billing 有 SVG 但**缺 stroke/fill=currentColor**(深色主题下不可见);
channels 声明 `icon = ""`(侧边栏空白);quota 连 icon 字段都没有。
统一成 inline SVG + `stroke="currentColor"`(照参考 src/sponsor.js:52 的
图标工厂形状)。

## 三、判据(4 条,全部做过变异验证)

- `TestChannelBaseURLDoesNotSwallowEndpoint`:base_url 里的路径段不得在
  endpoint 重复出现。**走真实加载插件**而非正则读源码 —— PLATFORMS 是
  Lua 表,有注释/条件拼装/末尾绑定循环,正则会给出假绿。
- `TestMinimaxUsesAnthropicProtocolAndRealInferPath`:路径 + 协议双断言。
- `TestCodeartsDeclaresRefreshHook`:分开报 has_refresh_fn / has_refresh_decl。
  只看 refreshable 不够 —— 那是"凭据类型可续",与"平台实现了续期"是两件事。
- `TestCodeartsRebuildsPemFromJwkAndSigns`:**必须真签名**。DER 结构错、
  长度错、base64 换行错都得到"非空但无效"的 PEM,只断言非空等于没验。
- `TestB64urlDecodeHandlesLongInput`:补一个真实测漏 —— 我的 b64urlDecode
  第一版在输出字节后没清高位(缺 `buf = buf % (2^nbits)`),43 字符的 JWK
  字段(32 字节)测出来**完全正确**,于是 PEM 判据在变异后依然全绿。
  只有 86 字符的长输入才暴露(double 精度耗尽 → string.char 抛 invalid value)。
  这条判据就是为"能跑、能返回、产物错"那类缺陷写的。
- `TestEveryPagePluginDeclaresSVGIcon` / `TestIconUsesCurrentColor`:
  按**页面插件清单**遍历而不是只覆盖 billing。billing 当年空串的判据只管
  它自己,于是 channels 空串、quota 缺字段时全绿 —— 同类缺陷换文件复发
  没有任何东西会响。这条测试还当场咬出了 billing 自己缺 currentColor。

变异验证:zcode base_url 回退 ✓ / minimax adapter 回退 ✓ / 删 refresh_fn
绑定 ✓ / 去掉 buf 掩码 ✓ / OID 多套一层 TLV ✓(5/5 全部咬住)

## 四、计费(诊断,未改价)

查清"计费不正确"的根因:`prices.models` 只配了 **2 条**
(deepseek-v4.1-flash + 它的 / 前缀形式),而实际在用 **193 个模型**
⇒ 112006 次请求里 **35672 次(31.8%)成本按 $0 计**。
其中 28203 次集中在 2026-10-05 一天(历史一次性事件);
当前每天 27~137 次,且**渠道模型全部未定价**
(Doubao-Seed-2.1-Pro / GLM-5.3-Flash / MiniMax-* / qwen3.6-plus / hy3)。

调研:参考插件从 **models.dev** 拉模型目录(含 cost.input/output)。
实测 models.dev 覆盖我们 139/193 模型(72%),但**不能取中位数** ——
同一模型不同 provider 价差可达数千倍(glm-5.2 从 0 到 1.82),
0 价多是订阅制/coding plan。按 host 匹配 provider 只覆盖 6/24 源。
⇒ 价格口径需要用户确认,本轮不动价表。

6 个新探针(仅测试/诊断用):__platform_def_probe(平台定义真相)、
__codearts_jwk_pem_probe(PEM 重建 + 真签名)、__b64_probe(解码字节)。
2026-10-19 13:43:27 +08:00

118 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"os"
"regexp"
"strings"
"testing"
)
// 侧边栏图标判据 —— 覆盖**所有**带页面的插件,不只是 billing。
//
// 为什么需要这个测试
// ------------------
// billing 当年声明了 icon = "",WebUI 把空串原样塞进按钮 ⇒ 侧边栏那条是空白。
// 当时只给 billing 加了判据(TestPluginIconIsNotARawEmoji)。后来 channels
// 声明 icon = ""、quota 连 icon 字段都没有 ⇒ 同样的"空白条目"缺陷又一次出现,
// 而判据只管 billing,所以全绿。
//
// 这就是"判据只覆盖出错的那一个文件"的代价:同类缺陷换个文件复发时没有任何
// 东西会响。本文件把范围改成**按页面遍历**,新增带页面的插件自动纳入。
// pageIcons 返回 插件文件 -> 其 page.icon 的声明值。
//
// 只认 page.icon(侧边栏读的就是它),不认顶层的其他 icon 字段 —— 那些不影响
// 侧边栏渲染,混进来会给出假绿。
func pageIcons(t *testing.T, path string) []string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
src := string(b)
// 短字符串:icon = "..."
shortRe := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`)
// 长字符串:icon = [==[ ... ]==](inline SVG 没法写成短字符串而不转义所有引号)
longRe := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`)
var out []string
for _, m := range shortRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
for _, m := range longRe.FindAllStringSubmatch(src, -1) {
out = append(out, m[1])
}
return out
}
// 带页面(plugin.ui.page)的插件。新增页面插件要登记在这里 ——
// 这是一份"必须被覆盖的清单",漏登记会让新插件逃避判据。
var pagePlugins = []string{
"plugins/billing.lua",
"plugins/channels.lua",
"plugins/quota.lua",
}
// TestEveryPagePluginDeclaresSVGIcon 钉住"每个带页面的插件都有 inline SVG 图标"。
//
// 两个必查项,各自对应一种真实缺陷:
// - 缺 icon / 空串 ⇒ 侧边栏空白(channels 的实测症状)
// - emoji ⇒ 尺寸不跟原生 tab、忽略主题色(billing 的历史缺陷)
func TestEveryPagePluginDeclaresSVGIcon(t *testing.T) {
for _, p := range pagePlugins {
t.Run(p, func(t *testing.T) {
icons := pageIcons(t, p)
if len(icons) == 0 {
t.Fatalf("%s 有任何 page 却没有任何 icon 声明 —— 侧边栏那条会是空白", p)
}
// 只断言"至少有一个非空且是 SVG":一个文件可能有多个页面,
// 但不能为了通过而声明一个空图标。
var ok bool
for _, ic := range icons {
if strings.TrimSpace(ic) == "" {
continue
}
if isEmojiIcon(ic) {
t.Errorf("%s 的 page icon 是 emoji %q —— 原生 tab 用 inline SVG"+
"(stroke: currentColor),emoji 尺寸不一致且不跟主题", p, ic)
continue
}
if !strings.Contains(ic, "<svg") {
t.Errorf("%s 的 page icon 不是 SVG 标记: %q", p, ic)
continue
}
ok = true
}
if !ok {
t.Errorf("%s 没有可用的 SVG page icon(声明的全是空串/emoji/非 SVG)", p)
}
})
}
}
// TestIconUsesCurrentColor 钉住图标**跟随主题色**。
//
// 参考插件的图标工厂(src/sponsor.js:52)统一用
// `stroke="currentColor"` + `strokeWidth 1.6`。写死颜色("#333"、"black")
// 会让图标在深色主题下几乎看不见 —— 那是同一个"图标不对"投诉的另一半。
func TestIconUsesCurrentColor(t *testing.T) {
for _, p := range pagePlugins {
t.Run(p, func(t *testing.T) {
for _, ic := range pageIcons(t, p) {
if strings.TrimSpace(ic) == "" || isEmojiIcon(ic) {
continue
}
if !strings.Contains(ic, "currentColor") {
t.Errorf("%s 的 icon 没写 stroke/fill=currentColor —— "+
"写死颜色在深色主题下不可见: %s", p, ic)
}
// 写死颜色的显式反例:出现 #rrggbb 或 rgb( 就报
if regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|rgba?\(`).MatchString(ic) {
t.Errorf("%s 的 icon 里含写死颜色: %s", p, ic)
}
}
})
}
}