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(解码字节)。
This commit is contained in:
JianFeeeee
2026-10-19 13:43:00 +08:00
parent fc5326402f
commit dcf327d3c5
6 changed files with 751 additions and 8 deletions

View File

@ -0,0 +1,184 @@
package core
import (
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
)
// codearts JWK→PEM 重建判据。
//
// 背景(真实缺陷,2026-10-19 用户报「华为云源用不了」):
//
// 华为云 STS 的 security_token 只活 1 小时,续期(refresh_token 换新)需要
// DPoP 请求签名。签名走 `codeartsDpopSign(key, ...)`,它要求 `key.pem` 是
// **PEM 字符串**(内部用 openssl 命令行签)。而持久化的凭据里**只有**
// `dpop_private_key_jwk`(私钥的 JWK)—— PEM 是登录时的临时文件,没保存。
//
// 于是续期必须先从 JWK 重建 PEM。这条判据钉住"重建出来的 PEM 真能被 openssl
// 接受并签出有效的 ES256 JWS",而不是"函数返回了非空字符串"。
//
// 为什么必须真签名:DER 结构错、长度字段错、base64 换行错,都会得到
// **非空但无效**的 PEM。只断言非空等于什么都没验。
func TestCodeartsRebuildsPemFromJwkAndSigns(t *testing.T) {
// 一个结构合法的 P-256 私钥 JWK(生产凭据的真实形状:三个字段都是
// 32 字节 base64url)。用固定值而非随机生成,是为了让失败可复现。
jwk := map[string]any{
"crv": "P-256",
"kty": "EC",
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U",
"x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM",
}
_, ps := poolCore(t)
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": jwk})
if err != nil {
t.Fatalf("探针调用失败: %v", err)
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
t.Fatalf("探针返回缺 json 包装: %#v", out)
}
if e, _ := inner["error"].(string); e != "" {
t.Fatalf("JWK→PEM 重建失败: %s", e)
}
// ① PEM 形状必须是 openssl 认的 SEC1 EC PRIVATE KEY
pem, _ := inner["pem"].(string)
if !strings.HasPrefix(pem, "-----BEGIN EC PRIVATE KEY-----") {
t.Fatalf("PEM 头部不对: %q", firstLine(pem))
}
if !strings.Contains(pem, "-----END EC PRIVATE KEY-----") {
t.Fatalf("PEM 尾部不对: %q", lastLine(pem))
}
// 每行(除首尾)不得超过 64 字符 —— openssl 对超长行会拒
for _, ln := range strings.Split(strings.TrimSpace(pem), "\n") {
if strings.HasPrefix(ln, "-----") {
continue
}
if len(ln) > 64 {
t.Fatalf("PEM base64 行长度 %d > 64,openssl 会拒绝", len(ln))
}
}
// ② 最关键:真签名必须成功(DER/长度/换行任一错都在这里暴露)
if ok, _ := inner["signed"].(bool); !ok {
t.Fatalf("PEM 重建后 DPoP 签名失败: %v", inner["sign_error"])
}
// ③ 签名产物必须是三段式 JWS(header.payload.signature)
// 探针不直接回 JWS,但 signed=true 已表示 codeartsDpopSign 走完了
// 整个 openssl 签名 + DER→raw 转换链路(任一环节失败都会返回 nil)。
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastLine(s string) string {
s = strings.TrimRight(s, "\n")
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
}
return s
}
// TestCodeartsKeyFromJwkRejectsBadJwk 钉住"坏 JWK 要响亮报错"。
//
// 为什么需要:JWK 字段长度不对(例如被截断、或根本不是 P-256)时,
// 若静默构造出一个畸形 PEM,openssl 只会说「签名失败」—— 运维无从判断
// 是凭据坏了还是代码坏了。必须在这里给可诊断的消息。
func TestCodeartsKeyFromJwkRejectsBadJwk(t *testing.T) {
cases := []struct {
name string
jwk map[string]any
}{
{"d 太短", map[string]any{
"kty": "EC", "crv": "P-256",
"d": "AAAA", "x": "r2HUYfG7tMIWY9KOpXLvvMWE_7dnHM9K7p1K7-LSwl4",
"y": "0-weI5p_EXZXFfGx4a76X_0oamkLOJ5AA4MOMObL8MM"}},
{"缺 x", map[string]any{
"kty": "EC", "crv": "P-256",
"d": "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, ps := poolCore(t)
out, err := ps.Call("channels", "__codearts_jwk_pem_probe", map[string]any{"jwk": c.jwk})
if err != nil {
return // 调用层报错也算响亮
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
return
}
if e, _ := inner["error"].(string); e == "" {
if ok, _ := inner["signed"].(bool); ok {
t.Fatalf("坏 JWK 竟然签出了名 —— 应当报错")
}
}
})
}
}
// 让 goimports/json 相关导入不被误判未使用(此文件仅用 json 做类型断言辅助)。
var _ = json.Marshal
var _ = os.Getenv
// TestB64urlDecodeHandlesLongInput 钉住 base64url 解码的**长输入**正确性。
//
// 这个判据补的是一个真实的测漏:我第一版的 b64urlDecode 在输出字节后
// **没有清除已用掉的高位**(缺 `buf = buf % (2^nbits)`)。用 43 字符的
// JWK 字段(32 字节)测时**结果完全正确** —— 于是那条 PEM 判据在变异后
// 依然全绿,什么也没验到。
//
// 只有长输入才暴露:累积缓冲不清高位时,double 精度在约 11 个 6bit 组后
// 耗尽,math.floor 给出越界值,string.char 直接抛 invalid value。
// 实测:把 `string.rep(43字符JWK, 10)` 喂进去,无掩码版本当场崩。
//
// 所以这里必须用**长串**(远超过 11 组),断言解出的字节数与内容都正确。
func TestB64urlDecodeHandlesLongInput(t *testing.T) {
// 96 个字符 → 72 字节。远超溢出临界点(11 组 = 66 bit)。
const in = "BpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7UBpkBLfUUij8IPn0Mzs6C64RfhQkF378yyQPeCR5sh7U"
// 期望值由 Go 标准库算出(不用手写的 Lua 结果做锚,否则自证)
want := base64.RawURLEncoding.EncodeToString
_, ps := poolCore(t)
out, err := ps.Call("channels", "__b64_probe", map[string]any{"s": in})
if err != nil {
t.Fatalf("探针调用失败(很可能是 string.char 越界 = 缺高位掩码): %v", err)
}
wrapped, _ := out.(map[string]any)
inner, _ := wrapped["json"].(map[string]any)
if inner == nil {
t.Fatalf("探针返回缺 json 包装: %#v", out)
}
got, _ := inner["hex"].(string)
n, _ := inner["len"].(float64)
// 期望长度由 Go 自己算(不要手推 len/4*3:输入不一定是 4 的倍数)
dec, derr := base64.RawURLEncoding.DecodeString(in)
if derr != nil {
t.Fatal(derr)
}
if int(n) != len(dec) {
t.Fatalf("解码长度 = %v,期望 %d", n, len(dec))
}
// 与 Go 解码结果逐字节比对
wantHex := ""
for _, b := range dec {
wantHex += string("0123456789abcdef"[b>>4]) + string("0123456789abcdef"[b&0xf])
}
if got != wantHex {
t.Fatalf("解码内容不符(长输入下高位未清除):\n got=%s\nwant=%s", got, wantHex)
}
_ = want
}

View File

@ -0,0 +1,146 @@
package core
import (
"strings"
"testing"
)
// 渠道端点判据 —— 钉住"base_url 不吞 endpoint 路径"这条铁律。
//
// 为什么需要这个测试
// ------------------
// 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。把路径**同时**
// 写进 base_url 和 endpoint,拼出来就是重复路径。生产实测两次撞上同一族缺陷:
//
// zcode base_url=.../api/v1/zcode-plan/anthropic
// endpoint=/api/v1/zcode-plan/anthropic/v1/messages
// ⇒ .../anthropic/api/v1/zcode-plan/anthropic/v1/messages → 404
//
// minimax base_url=https://agent.minimax.cn + adapter=openai(默认 endpoint
// /chat/completions)⇒ 打到 Web 前端首页,404「简单指令,无限可能」
//
// 两者报错都是 404,但根因不同(路径重复 / 缺路径段)。单条断言咬不住两种,
// 所以下面分三条断言。
// platformDef 从**真实加载的插件**里读一个平台定义。
//
// ★ 必须走真实加载而不是正则解析源码:PLATFORMS 是 Lua 表,源里有注释、
// 条件拼装、以及文件末尾的绑定循环(refresh_fn / claim_fn / headers_fn 都在
// 那里挂)。拿正则在源文本上判断"有没有 refresh 段"会被注释里的字样骗 ——
// 这个坑我上一轮差点踩进去。探针返回的是插件运行时看到的真相。
func platformDef(t *testing.T, id string) map[string]any {
t.Helper()
_, ps := poolCore(t)
out, err := ps.Call("channels", "__platform_def_probe", map[string]any{"id": id})
if err != nil {
t.Fatalf("__platform_def_probe(%s) 失败: %v", id, err)
}
// Plugins.Call 返回 {json:{...}, status:...} 包装(历史约定)。
wrapped, ok := out.(map[string]any)
if !ok {
t.Fatalf("探针返回不是表: %T", out)
}
if e, ok := wrapped["error"].(string); ok && e != "" {
t.Fatalf("%s: %s", id, e)
}
inner, ok := wrapped["json"].(map[string]any)
if !ok {
t.Fatalf("探针返回缺 json 包装: %#v", wrapped)
}
return inner
}
// TestChannelBaseURLDoesNotSwallowEndpoint 是最核心的一条:
// base_url 里**不得**出现 endpoint 也有的路径段。
//
// 判别性:zcode 缺陷时 base_url 尾段是 "anthropic",endpoint 里也含
// "/anthropic/" ⇒ 命中。修好后 base_url 只到 host,永不命中。
func TestChannelBaseURLDoesNotSwallowEndpoint(t *testing.T) {
for _, id := range []string{"zcode", "minimax", "codearts", "trae", "lobsterai", "buddy"} {
t.Run(id, func(t *testing.T) {
p := platformDef(t, id)
base, _ := p["base_url"].(string)
ep, _ := p["endpoint"].(string)
base = strings.TrimRight(base, "/")
if base == "" {
t.Fatalf("%s 没有 base_url", id)
}
// 从 base_url 里取出路径部分(host 之后),逐段看是否也出现在 endpoint 里。
pathPart := ""
if i := strings.Index(base, "://"); i >= 0 {
if j := strings.Index(base[i+3:], "/"); j >= 0 {
pathPart = base[i+3+j:]
}
}
if pathPart == "" {
return // 只到 host,天然不会重复
}
for _, seg := range strings.Split(strings.Trim(pathPart, "/"), "/") {
if seg == "" {
continue
}
if strings.Contains(ep, "/"+seg+"/") || strings.HasSuffix(ep, "/"+seg) {
t.Fatalf("%s: base_url 已含路径段 %q(base_url=%s),"+
"endpoint 里又出现(endpoint=%s)⇒ 内核拼 base_url+endpoint 时路径重复",
id, seg, base, ep)
}
}
})
}
}
// TestMinimaxUsesAnthropicProtocolAndRealInferPath 钉住 minimax 的两层缺陷。
//
// 参考 channel-pack/src/minimax-product.ts:
//
// MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages' ← Anthropic Messages
// apiHost = 'https://agent.minimax.cn'
//
// 缺陷 A:base_url 只到 host + adapter=openai ⇒ 请求 /chat/completions
// ⇒ 那是 Web 前端,404 首页。实测(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
// 缺陷 B:即使路径改对,openai 适配器的**请求体形状**也不对
// (minimax 推理端点是 Anthropic 协议)。
func TestMinimaxUsesAnthropicProtocolAndRealInferPath(t *testing.T) {
p := platformDef(t, "minimax")
ep, _ := p["endpoint"].(string)
adapter, _ := p["adapter"].(string)
if !strings.HasSuffix(ep, "/mavis/api/v1/llm/v1/messages") {
t.Errorf("minimax endpoint = %q,应为 /mavis/api/v1/llm/v1/messages"+
"(照参考 MINIMAX_INFER_PATH);否则请求会打到 Web 前端", ep)
}
if adapter != "anthropic" {
t.Errorf("minimax adapter = %q,应为 anthropic —— 推理端点是 Anthropic "+
"Messages 协议,用 openai 适配器会把请求体拼错", adapter)
}
}
// TestCodeartsDeclaresRefreshHook 钉住"声明了可续期就必须声明怎么续"。
//
// 真实缺陷(2026-10-19 用户报「华为云源用不了」):
// codearts 的 security_token 只活 1 小时;account_refresh 走 refreshCredential(),
// 它要求 plat.refresh.url 或 plat.refresh_fn 存在。当时只声明了 refreshable=true
// (那是 accountView 里硬编码的期望),**没声明怎么续** ⇒ 恒出
// 「该平台未声明续期方式」,自动续期链空转,日志反复 `模型刷新失败 HTTP 400`。
//
// 为什么不能只看 refreshable:那是"这个账号的凭据类型可续",与"平台实现了
// 续期"是两件事。前者为真而后者缺失,正是这个 bug 的形状。
func TestCodeartsDeclaresRefreshHook(t *testing.T) {
p := platformDef(t, "codearts")
hasHook, _ := p["has_refresh_fn"].(bool)
hasDecl, _ := p["has_refresh_decl"].(bool)
if !hasHook && !hasDecl {
t.Fatal("codearts 既无 refresh_fn 也无 refresh.url —— " +
"account_refresh 会恒返回「该平台未声明续期方式」," +
"而 STS token 只有 1 小时寿命,过期后源的每个请求都会 400")
}
// 华为云 STS 要 DPoP 头,声明式 style 拿不到私钥 ⇒ 必须是钩子
if !hasHook {
t.Error("codearts 只有声明式 refresh.url,但华为云 STS 需要 DPoP 头" +
"(jwk 在凭据里),声明式 style 发不出来 ⇒ 必须用 refresh_fn")
}
}

View File

@ -0,0 +1,117 @@
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)
}
}
})
}
}

View File

@ -602,7 +602,7 @@ plugin.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
order = 40,
mount = [==[
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">

View File

@ -116,7 +116,7 @@ plugin.kernel = {
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_clear_credentialless", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_pool_reason", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__set_loopback_probe", "reap_loopbacks", "reap_orphan_sources", "__add_source_for_test", "__forget_account_probe", "__set_pool_cred_probe", "__cred_headers_probe", "__b64_probe", "__codearts_jwk_pem_probe", "__platform_def_probe", "__zcode_diag_probe", "__trae_entry_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" }
@ -385,13 +385,27 @@ local PLATFORMS = {
},
hosts = { "account.minimax.cn", "agent.minimax.cn" },
base_url = "https://agent.minimax.cn",
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核的 URL 是 `base_url + endpoint`(provider.go ChatURL)。
-- 此前 base_url 只写 host、adapter=openai,于是请求打到
-- https://agent.minimax.cn/chat/completions —— 那是 **Web 前端**,
-- 返回 404 首页文案「MiniMax Agent: 简单指令,无限可能」。
-- 实测(2026-10-19):/mavis/api/v1/llm/v1/messages → 401 invalid
-- access token(=路径正确、凭据问题);裸 /chat/completions 与
-- /mavis/api/v1/chat/completions 都是 404。
-- 照参考 minimax-product.ts MINIMAX_INFER_PATH。
endpoint = "/mavis/api/v1/llm/v1/messages",
-- 远端模型目录(照参考 minimax-product.ts MINIMAX_MODELS_PATH)
models_remote = {
endpoints = { { url = "https://www.minimaxi.com/mavis/api/v1/models", method = "GET" } },
auth = "bearer",
parse = "id_list",
},
adapter = "openai",
-- ★ 推理端点是 **Anthropic Messages 协议**(不是 OpenAI):
-- 参考 minimax-product.ts 的 `MINIMAX_INFER_PATH = '/mavis/api/v1/llm/v1/messages'`
-- 与 anthropic 基座 `adapter.endpoint = "/v1/messages"` 同族。
-- 此前写成 openai ⇒ 即使路径改对,请求体形状也不对。
adapter = "anthropic",
-- 照参考 MINIMAX_FALLBACK_MODELS(4 项)。远端权威:
-- GET /mavis/api/v1/models?region=cn&buildEnv=prod
models = { "MiniMax-M3.1-Flash-Preview", "MiniMax-M3", "MiniMax-M2.7-highspeed", "MiniMax-M2.7" },
@ -969,6 +983,27 @@ local PLATFORMS = {
},
-- claim_fn 由下方 claimCodeArts 实现(引用前向声明,见文件末尾)
claim_fn_name = "claimCodeArts",
-- ★ 续期:华为云 STS 的 security_token 只活 1 小时,不续就会
-- `APIG.0602: the security token has expired` 拒掉每个请求。
--
-- 为什么必须在这里声明(真实缺陷,2026-10-19 用户报「华为云源用不了」):
-- account_refresh 走 refreshCredential(),它要求 `plat.refresh.url` 存在;
-- codearts 只声明了 refreshable=true(在 accountView 里硬编码)却没声明
-- **怎么续** ⇒ 手动刷新恒出 `该平台未声明续期方式`,自动续期链空转,
-- 日志里反复 `模型刷新失败 codearts: HTTP 400`。
--
-- 为什么不能走 REFRESH_STYLES 的声明式风格:
-- 华为云 STS 要 **DPoP 头**(ES256 JWS,jwk 用凭据里存的 dpop_private_key_jwk
-- 签),而声明式 style 只发普通 JSON/form 且拿不到私钥。已有实现
-- `codeartsRequestToken(body, key)` 做完整这件事 —— 故用 refresh_fn
-- 挂钩它,而非新增声明式 style(保持内核/通用层不理解厂商)。
--
-- 请求体真值(照参考 channel-pack/src/oauth.ts exchangeRefreshToken):
-- client_id=codearts-agent grant_type=refresh_token
-- refresh_token=<凭据里的> code_verifier=<登录时的>
-- ⚠️ client_id 是 **codearts-agent**,不是凭据里的 access_key_id
-- (我实测把 AK 当 client_id 会得到 `invalid client id`)。
-- 实际绑定在文件末尾的绑定循环(refresh_fn),不在本表内联。
},
-- Qoder —— **标准 PKCE 设备码轮询**(照参考 qoder-oauth.ts / qoder.ts 逐行移植)。
@ -1097,8 +1132,16 @@ local PLATFORMS = {
-- 只有订阅制才走 api.z.ai。这里用积分制那条。
-- zcode 只存在 /api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径
-- (参考 zcode-anthropic.ts:7 注释:openai / 裸 v1/chat/completions 一律
-- 404 page not found)。适配器 anthropic 补 /v1/messages。
base_url = "https://zcode.z.ai/api/v1/zcode-plan/anthropic",
-- 404 page not found)。
--
-- ★ 路径必须在 endpoint 里,**不能**塞进 base_url:
-- 内核 URL = `base_url + endpoint`(provider.go ChatURL)。
-- 此前 base_url 已含 /api/v1/zcode-plan/anthropic,而下面又声明了
-- endpoint = "/api/v1/zcode-plan/anthropic/v1/messages" ⇒ 路径被拼两遍:
-- https://zcode.z.ai/api/v1/zcode-plan/anthropic/api/v1/zcode-plan/anthropic/v1/messages
-- → 404 page not found(生产实测 2026-10-19)。
-- 与 v2.0.30「12 平台 base_url 侵占 endpoint」同族缺陷。
base_url = "https://zcode.z.ai",
-- 远端模型目录(照参考 zcode-upstream.ts ZCODE_CLIENT_CONFIGS_URL)
models_remote = {
endpoints = { { url = "https://zcode.z.ai/api/v1/client/configs", method = "GET",
@ -1459,6 +1502,104 @@ local function b64urlRaw(bin)
return (e:gsub("+", "-"):gsub("/", "_"):gsub("=", ""))
end
-- b64urlDecode:base64url → 二进制。
--
-- 为什么自己写:内核只导出 `base64_encode`(vm.go:1363),**没有**decode。
-- 而续期需要把 JWK 的 d/x/y(都是 base64url 字符串)转回原始字节。
local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local b64rev = {}
for i = 1, #b64chars do b64rev[b64chars:sub(i, i)] = i - 1 end
local function b64urlDecode(s)
s = tostring(s or ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
local out, buf, nbits = {}, 0, 0
for i = 1, #s do
local v = b64rev[s:sub(i, i)]
if v then
buf = buf * 64 + v
nbits = nbits + 6
-- ★★ `buf = buf % (2 ^ nbits)` 是必须的:只在取字节时 % 256 而不清掉
-- 已用掉的高位,buf 会持续累积。短输入(32 字节)碰巧全对,长输入
-- 则 double 精度耗尽、math.floor 给出越界值 ⇒ string.char 抛
-- invalid value。判据 TestB64urlDecodeHandlesLongInput 专钉这条。
while nbits >= 8 do
nbits = nbits - 8
out[#out + 1] = string.char(math.floor(buf / (2 ^ nbits)) % 256)
buf = buf % (2 ^ nbits)
end
end
end
return table.concat(out)
end
-- derLen:DER 长度字段(短/长格式)。
local function derLen(n)
if n < 128 then return string.char(n) end
local bytes = {}
while n > 0 do
table.insert(bytes, 1, string.char(n % 256))
n = math.floor(n / 256)
end
return string.char(0x80 + #bytes) .. table.concat(bytes)
end
-- derTLV(tag, value):最小 DER 编码单元。
local function derTLV(tag, value)
return tag .. derLen(#value) .. value
end
-- codeartsKeyFromJwk(jwk):把持久化的 DPoP 私钥 JWK **重建**成可签名 key。
--
-- 为什么需要它(真实缺陷,2026-10-19):
-- 登录时 `codeartsNewDpop()` 生成 PEM,返回 { pem, public, private };
-- 但凭据里**只持久化了 private(JWK)** —— pem 是临时文件,登录后即删。
-- 而 `codeartsDpopSign(key,...)` 走 openssl 命令行,**必须**要 `key.pem`
-- 字符串。直接把 { private = jwk } 丢进去会在 `pf:write(key.pem)` 报
-- `bad argument #1 to 'write' (string expected, got nil)`。
--
-- 做法:用 d/x/y 手工组 SEC1 `EC PRIVATE KEY` 的 DER,再 PEM 包装。
-- 结构与 openssl 生成的一致(已用实测 PEM 对照过字段顺序):
-- SEQUENCE
-- INTEGER 1 (version)
-- OCTET STRING <32B d> (privateKey)
-- [0] OID 1.2.840.10045.3.1.7 (prime256v1)
-- [1] BIT STRING <04||X||Y> (publicKey)
--
-- 输出形状与 codeartsNewDpop() **完全一致**(pem + public + private),
-- 于是 codeartsDpopSign / codeartsCredentialFrom 无需分叉。
local function codeartsKeyFromJwk(jwk)
local d = b64urlDecode(jwk.d)
local x = b64urlDecode(jwk.x)
local y = b64urlDecode(jwk.y)
if #d ~= 32 or #x ~= 32 or #y ~= 32 then
return nil, "DPoP 私钥 JWK 字段长度异常(d=" .. #d .. " x=" .. #x .. " y=" .. #y .. ")"
end
-- OID 1.2.840.10045.3.1.7 (prime256v1) 的 DER 字节
-- OID 1.2.840.10045.3.1.7(prime256v1)的**完整 TLV**(\6=tag \8=len)。
-- ★ 这里只需一次 TLV:字符串里已有 tag+len,再套一层会得到
-- 06 0a 06 08 … —— DER 长了 2 字节,openssl 报
-- `Could not find private key` / `provider=default`,而**不**说结构错。
-- 我第一版就是这样(真实 PEM 121 字节 vs 我的 123 字节)。
local oid = "\6\8*\134H\206=\3\1\7"
local pub = "\4" .. x .. y -- 未压缩点:04 || X || Y
local body =
derTLV("\2", "\1") .. -- INTEGER 1
derTLV("\4", d) .. -- OCTET STRING d
derTLV("\160", oid) .. -- [0] parameters(oid 已含 tag+len)
derTLV("\161", derTLV("\3", "\0" .. pub)) -- [1] publicKey(BIT STRING 前导 0 位)
local der = derTLV("\48", body) -- SEQUENCE
-- PEM 包装:EC PRIVATE KEY + base64 + 64 字符换行
local b64 = base64_encode(der)
local lines = { "-----BEGIN EC PRIVATE KEY-----" }
for i = 1, #b64, 64 do lines[#lines + 1] = b64:sub(i, i + 63) end
lines[#lines + 1] = "-----END EC PRIVATE KEY-----"
return {
pem = table.concat(lines, "\n") .. "\n",
public = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y) },
private = { kty = "EC", crv = "P-256", x = b64urlRaw(x), y = b64urlRaw(y),
d = b64urlRaw(d) },
}, nil
end
-- ============================================================================
-- CodeArts(华为)登录:新式 IAM OAuth(PKCE + DPoP ES256)
-- ============================================================================
@ -1676,6 +1817,63 @@ local function codeartsCredentialFrom(token, pkce, key)
}
end
-- refreshCodeArts(plat, cred):用已有 refresh_token 静默续一次华为云 STS token。
--
-- 照参考 channel-pack/src/oauth.ts `exchangeRefreshToken`:
-- requestToken({ client_id: CLIENT_ID, code_verifier,
-- grant_type: GRANT_REFRESH_TOKEN, refresh_token }, keyPair)
-- 请求体四个字段缺一不可(少 code_verifier 会被拒)。
--
-- ★ 两个易错点(我自己踩过,2026-10-19):
-- 1. client_id 是 **codearts-agent**(CODEARTS.client_id),
-- **不是**凭据里的 access_key_id —— 把 AK 当 client_id 会得到
-- `STS5.1806 invalid refresh token: 'invalid client id: HSTACS...'`。
-- 2. **refresh_token 是一次性的**:换成功一次后旧值立即作废
-- (再发一次得 `the refresh token has been used`)⇒ 响应里的新
-- refresh_token **必须**回写,否则下一轮永久失去续期能力。
-- 这正是本函数用 codeartsCredentialFrom 重组整个凭据的原因。
--
-- 返回 (newCredTable, nil) | (nil, err)。失败信息要能定位(带上游 error_code)。
local function refreshCodeArts(plat, cred)
local rtoken = cred.refresh_token
local verifier = cred.code_verifier
if type(rtoken) ~= "string" or rtoken == "" then
return nil, "无 refresh_token,需重新登录"
end
if type(verifier) ~= "string" or verifier == "" then
return nil, "无 code_verifier,需重新登录"
end
local jwk = cred.dpop_private_key_jwk
if type(jwk) ~= "table" then return nil, "无 DPoP 私钥,需重新登录" end
-- DPoP 私钥必须从凭据里恢复(同一个 key 才能让 STS 认这是同一客户端)。
--
-- ★ 这里有一个真实的形状差异(我自己踩过):
-- `codeartsNewDpop()` 返回 { pem, public, private },但凭据里
-- **只保存了 private(JWK)** —— pem 是登录时的临时文件,没有持久化。
-- 而 `codeartsDpopSign(key, ...)` 要的是 `key.pem` 字符串(它走
-- openssl 命令行签名)。所以直接传 { private = jwk } 会在
-- `pf:write(key.pem)` 报 `bad argument #1 to 'write' (string expected, got nil)`。
--
-- 修法:用 JWK 的 d/x/y 重建 PEM(EC PRIVATE KEY 的 SEC1 格式,
-- openssl 认得)。重建后的 key 同时含 pem / public / private,
-- 与 codeartsNewDpop() 的返回形状一致,后续逻辑无需分叉。
local key, kerr = codeartsKeyFromJwk(jwk)
if not key then return nil, tostring(kerr) end
local token, terr = codeartsRequestToken({
client_id = CODEARTS.client_id,
code_verifier = verifier,
grant_type = "refresh_token",
refresh_token = rtoken,
}, key)
if not token then return nil, tostring(terr) end
-- token.refresh_token 若上游给了新的就用新的;没给则沿用旧值
-- (华为云实测会返回新的;保留旧值做兜底不会更差)
if type(token.refresh_token) ~= "string" or token.refresh_token == "" then
token.refresh_token = rtoken
end
return codeartsCredentialFrom(token, { verifier = verifier }, key)
end
-- buildCodeartsAuthorizeUrl(port, pkce, ticketId):照参考 buildOAuthLoginUrl 逐字拼。
-- ⚠️ 三处易错(参考注释都点名了):
-- 1. `code_challenge_method=SHA-256` —— 不是 RFC 的 S256!portal 以此识别
@ -2227,6 +2425,20 @@ do
break
end
end
-- 绑定 codearts 的续期钩子。
--
-- ★ 为何必须在这里挂而不是在平台表里内联:
-- PLATFORMS 在第 ~900 行,而 refreshCodeArts 定义在第 ~1720 行。
-- 在表里写 `refresh_fn = function(...) return refreshCodeArts(...) end`
-- 会闭包捕获**当时的全局 nil**(该名还是 nil)—— 调用时抛错,
-- 与 trae 那次 headers_fn 同一坑(当时只加 auth 无效、表内内联也无效)。
-- 在文件末尾绑定则天然拿到已定义的值。
for _, p in ipairs(PLATFORMS) do
if p.id == "codearts" then
p.refresh_fn = function(plat, cred) return refreshCodeArts(plat, cred) end
break
end
end
end
@ -3272,11 +3484,31 @@ local REFRESH_STYLES = {
-- refreshCredential(plat, acct):真发一次续期请求,成功则**回写凭据与索引**。
-- 返回 (ok, message)。失败原因必须可诊断(不能只说"失败了")。
--
-- 两种续期方式:
-- * `plat.refresh`(声明式):普通 JSON/form OAuth —— 大多数平台
-- * `plat.refresh_fn`(钩子):需要签名的(华为云 STS 要 DPoP 头,
-- 而声明式 style 拿不到私钥)
-- 钩子签名:refresh_fn(plat, cred) -> (newCredTable, nil) | (nil, err)
-- 钩子自己负责写密文(因为写入路径只有一个:__llmsproxy_secrets.set)。
local function refreshCredential(plat, acct)
local r = plat.refresh
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
local cred = credentialFull(acct.credentialRef)
if type(cred) ~= "table" then return false, "凭据无法解析" end
-- ★ 钩子优先:需要签名的平台无法用声明式 style 表达
-- (DPoP 私钥不在 style 的可见范围内)。
if type(plat.refresh_fn) == "function" then
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
local newCred, ferr = plat.refresh_fn(plat, cred)
if not newCred then return false, tostring(ferr or "续期失败") end
__llmsproxy_secrets.set(acct.credentialRef, json.encode(newCred))
if newCred.expires_at then
local ms = credentialExpiresAtMs(newCred)
if ms then acct.expiresAt = math.floor(ms / 1000) end
end
return true, "已续期"
end
local r = plat.refresh
if type(r) ~= "table" or not r.url then return false, "该平台未声明续期方式" end
if not credentialIsRefreshable(cred) then return false, "无 refresh_token,需重新登录" end
local style = REFRESH_STYLES[r.style or "google"]
if not style then return false, "未知续期风格: " .. tostring(r.style) end
@ -11180,7 +11412,12 @@ plugin.ui = {
page = {
page_id = "channels",
title = "账号接入",
icon = "",
-- ★ 侧边栏图标必须是 **inline SVG**(不能用 emoji、不能用空串)。
-- 空串会让侧边栏那条变成空白(billing 曾经就这样,被
-- TestPluginIconIsNotARawEmoji 钉住);emoji 则在原生 tab 里
-- 尺寸不一致且不跟主题色。形状照 billing:描边接 currentColor。
-- 图案:三个节点 + 连线的「多路接入」语义。
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="18" cy="6" r="2.6"/><circle cx="12" cy="18" r="2.6"/><path d="M8.2 7.6 10.6 15.4M15.8 7.6 13.4 15.4M8.6 6h6.8"/></svg>]==],
order = 40,
mount = [[
<div id="channels-page">
@ -12142,6 +12379,62 @@ function plugin.__trae_entry_probe(args)
} }
end
-- plugin.__platform_def_probe:只给测试用 —— 把平台定义的**关键字段**暴露成值。
--
-- 为何必须走真实加载而不能正则搅源码:
-- PLATFORMS 是 Lua 表,源里有注释、条件拼装、以及文件末尾的绑定循环
-- (refresh_fn / claim_fn / headers_fn 都在那里挂)。用正则在源文本上找
-- "有没有 refresh 段"会被注释里的字样骗 —— 我上一轮就差点这么错。
-- 本探针返回的是**插件运行时看到的真相**。
function plugin.__platform_def_probe(args)
args = args or {}
local plat = platformById(tostring(args.id or ""))
if not plat then return { json = { error = "未知平台" } } end
return { json = {
id = plat.id,
base_url = plat.base_url or "",
endpoint = plat.endpoint or "",
endpoint_stream = plat.endpoint_stream or "",
adapter = plat.adapter or "openai",
-- 续期能力:**三种声明形态**分开报,别用一个布尔把两件事混在一起
has_refresh_fn = type(plat.refresh_fn) == "function",
has_refresh_decl = type(plat.refresh) == "table" and plat.refresh.url ~= nil,
} }
end
-- plugin.__b64_probe:只给测试用 —— 暴露 b64urlDecode 的字节输出(hex)。
--
-- 为什么需要它:b64urlDecode 的 bug 形状是「短输入正确、长输入崩/错」——
-- 累积缓冲不清高位时,double 精度在约 11 个 6bit 组后耗尽,
-- math.floor 会给出越界值(string.char 直接报 invalid value)。
-- 只用 43 字符的 JWK 字段测不出来,必须有一条**长输入**判据。
function plugin.__b64_probe(args)
args = args or {}
local bin = b64urlDecode(tostring(args.s or ""))
local hx = {}
for i = 1, #bin do hx[#hx + 1] = string.format("%02x", bin:byte(i)) end
return { json = { len = #bin, hex = table.concat(hx) } }
end
-- plugin.__codearts_jwk_pem_probe:只给测试/诊断用 —— 验证 JWK→PEM 重建。
--
-- 为什么需要它:PEM 重建是纯字节操作(DER 手工组 + base64 + 换行),出错时
-- openssl 只会说「签名失败」,无法定位是 DER 结构错、长度错还是换行错。
-- 这个探针把中间产物(PEM 文本)暴露出来,并直接跑一次真实签名。
function plugin.__codearts_jwk_pem_probe(args)
args = args or {}
local jwk = args.jwk
if type(jwk) ~= "table" then return { json = { error = "need jwk" } } end
local key, kerr = codeartsKeyFromJwk(jwk)
if not key then return { json = { error = tostring(kerr) } } end
local sig, serr = codeartsDpopSign(key, "POST", CODEARTS.sts_token)
return { json = {
pem = key.pem,
signed = sig ~= nil,
sign_error = serr and tostring(serr) or nil,
} }
end
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
--
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在

View File

@ -1065,6 +1065,9 @@ PLUGIN.ui = {
page = {
page_id = "quota",
title = "Quota",
-- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。
-- 图案:三格电池 —— “套餐余量”的直接语义。
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="16" height="10" rx="2.4"/><path d="M21.5 10.5v3"/><path d="M6 10.5v3M10 10.5v3M14 10.5v3"/></svg>]==],
order = 45,
mount = [==[
<div id="qa-root" class="qa-root">