mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
117
internal/lua/plugin_icons_test.go
Normal file
117
internal/lua/plugin_icons_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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 判据模块。
|
||||
--
|
||||
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在
|
||||
|
||||
@ -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">
|
||||
|
||||
Reference in New Issue
Block a user