Compare commits

...

2 Commits

Author SHA1 Message Date
1f29be43db feat(channels): credits_pool_reason —— 区分「真的用尽」与「查不到」
## 为什么需要(参考同款修复,用户报障 2026-09-29)
把两者混成一句"额度都已用尽"会让**一次网络抖动伪装成钱花完了** —— 用户于是
去解锁永久积分、或白等到明天,而号里其实有钱。两者的**正确动作完全相反**:
  exhausted → 等额度重置
  unknown   → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
  locked    → 可解锁永久积分(同样不是"用尽")

我们此前**没有**这个出口:`credits_balances` 只报逐账号错误,调用方拿不到
"为什么整池不可用"的结论,只能自己猜。

## 实现
新增 `channels/credits/pool_reason`,返回 `reason` ∈
`no_accounts | unknown | usable | locked | exhausted` + 可读 detail。
判定优先级(顺序即语义):
  no_accounts(无启用账号)
  → unknown(平台未声明余额端点 —— **无法判定 ≠ 用尽**)
  → usable(有可用账号)
  → unknown(**有查询失败** —— 不能当成用尽)
  → locked(锁定且只剩永久积分)
  → exhausted(都查到了且确为 0)

## 判据(6 条 + 变异 2/2)
internal/core/channels_pool_reason_test.go
- 余额确为 0 → exhausted
- 查询失败 → unknown(且说明必须点明"查询失败")
- 无启用账号 → no_accounts
- 有钱 → usable
- 无余额端点 → unknown(不编造用尽)
- 锁定且只剩永久积分 → locked,且**解锁后同一份余额变 usable**
  (证明 locked ≠ exhausted)
变异:查询失败也报 exhausted → 报红;locked 混同 exhausted → 报红。

## 踩到的坑(都是我的判据/夹具问题,非实现)
① 夹具用扁平 `{"total":100}`,而 buddy 的余额是**深层嵌套**
   `data.Response.Data.Accounts[]`(每包含 Status/CycleCapacityRemain/
   DeductionEndTime)—— 夹具形状不对导致全部走 unknown。
② `addAccount` 写死 platform="buddy",于是"无余额端点"那条用例在 minimax 上
   其实没有账号,命中优先级更高的 no_accounts(**正确**行为)却被判成错。
   加 `addAccountOn(platform, id)` 修正。
③ Lua 里嵌套引号:`"(...不是"额度用尽")"` 语法错 ⇒ 改用中文引号「」。
2026-10-16 09:00:47 +08:00
8109acf7cd feat(trae): batch 模型目录(含参考记录的两个真实缺陷修复 + 元数据)
我们此前 trae 的模型列表是**纯硬编码**(32 项名字),既没有真实元数据,也没打
batch 端点。参考的 parseTraeBatchModelList 记录了**两个已修的缺陷**,移植时
必须一起带上,否则等于把 bug 再引进一遍:

## ① 通道白名单是「准入」而不只是「排序」(issue IKJOZ7)
22 个 function 里有一半在本插件推理端点下**不可调用**(实测 chat→4023
model unknown、builder→4001、inline_chat→3003)。只把白名单用于"同模型择优"
的旧写法 = "目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,而错误文案
指向**模型**,极易被误判成"这个模型坏了"。现在不在表内的通道**整组丢弃**。

## ② 空档位不得覆盖有档位(issue IKI7WT/IKILR7)
旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据**相反**
—— 上游把空档位的 solo_work_lite/solo_design_remote 排在**最后**,实测 13 个
模型因此丢掉 reasoning_effort_config、全部显示"未提供推理等级"。
合并规则(与参考逐条对齐):空档位不得覆盖有档位;两侧都有档位时按通道优先级
取更靠前者;其余保持"后覆盖前"。

## ③ is_custom_model 是「仅可见但不可调用」的权威判据
该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志随服务端下发变化,
曾据名字把 qwen3.8-flash 误记为应剔除(它其实正常可用)。

## 附带:远端目录**元数据**不再丢
原来目录只回 id ⇒ 参考能提供的上下文窗口/输出上限/思考档位/消耗倍率全丢。
现按参考存下完整条目并在 model_list 以 `remote` 字段报出。
⚠ contextWindow 取 `context_window_tokens.dev` 而非 `max`:max 需开启
display_config.max_mode 才可用(我们不实现该开关),采信 max 会让上层以为有
1M 窗口、实际被上游拒。

## 基础设施
- refreshRemoteModels 支持 ep.body(POST 型目录端点)与 ep.sign(复用平台既有
  的头部构造,不另写一套 —— 否则"chat 能通、目录 401"极难查)。
- trae 走 sign="trae_solo" 复用 traeCheckinHeaders(Cloud-IDE-JWT + 约 20 个
  客户端头;参考实测简化头会被拒)。
- 新增 __trae_entry_probe(诊断"合并规则选中了哪条"),此前只能靠猜。

## 判据(3 条 + 变异 3/3)
internal/core/channels_trae_catalog_test.go
- 白名单准入 + 档位来源(空档位不得覆盖有档位)
- 四条硬性过滤(usage/config_switch/is_invisible_to_user/is_custom_model)
  且 **is_custom_model=false 的 custom_model_* 条目必须保留**(判据是标志值)
- 请求体必须传全部 22 个 function(与真实 IDE 一致;只传聊天通道会让非聊天
  通道条目位置错乱甚至被跳过)
变异:白名单不做准入 → 报红;无条件后覆盖前 → 报红;不过滤 custom → 报红。

★ 调试中发现并修掉我自己写的一个**静默失效**:
`declaresEffort` 读 `e.reasoning_effort_config` 而 `parseEntry` 存的是
`e.reasoning` ⇒ 判定恒为 false ⇒ 规则①永不生效(正是它要防的缺陷)。
判据当场抓到。**教训:跨函数用同一份数据时,字段名必须对齐 —— 这类不一致
不会报错,只会让规则静默失效。**

验证:internal/core 369s 全绿。
2026-10-16 08:28:36 +08:00
3 changed files with 888 additions and 2 deletions

View File

@ -0,0 +1,225 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"llmsproxy/internal/lua"
)
// ★ 「为什么没有可用账号」必须区分**真的用尽**与**查不到**。
//
// 参考的同款修复(用户报障 2026-09-29):混成一句"额度都已用尽"会让
// **一次网络抖动伪装成钱花完了** —— 用户于是去解锁永久积分、或白等到明天,
// 而号里其实有钱。两者的**正确动作完全相反**:
//
// exhausted → 等额度重置
// unknown → 稍后重试 / 查网络与凭据(**不要**动永久积分锁定)
//
// 判据看的是**报出的 reason**(用户据此决定做什么),不是内部计数。
func TestCreditsPoolReasonDistinguishesExhaustedFromUnknown(t *testing.T) {
t.Run("账号都查到了余额但为 0 → exhausted", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(0, 0)
got := h.reason()
if got["reason"] != "exhausted" {
t.Fatalf("余额确为 0 时应报 exhausted(用户该等重置),实得 %v:%v",
got["reason"], got["detail"])
}
})
t.Run("余额查询失败 → unknown(不能伪装成用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalanceError(500)
got := h.reason()
if got["reason"] != "unknown" {
t.Fatalf("查询失败必须报 unknown —— 报 exhausted 会让网络抖动伪装成钱花完了"+
"(用户会去解锁永久积分或白等),实得 %v:%v", got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "查询失败") {
t.Fatalf("说明必须点明是查询失败,实得 %q", d)
}
})
t.Run("没有启用的账号 → no_accounts", func(t *testing.T) {
h := newPoolReasonHarness(t)
got := h.reason()
if got["reason"] != "no_accounts" {
t.Fatalf("无账号时应报 no_accounts,实得 %v", got["reason"])
}
})
t.Run("有可用账号 → usable", func(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
h.serveBalance(100, 0)
got := h.reason()
if got["reason"] != "usable" {
t.Fatalf("有钱时应报 usable,实得 %v", got["reason"])
}
if u, _ := got["usable"].(float64); int(u) != 1 {
t.Fatalf("usable 应为 1,实得 %v", got["usable"])
}
})
t.Run("平台无余额端点 → unknown(不编造用尽)", func(t *testing.T) {
h := newPoolReasonHarness(t)
// ⚠ 必须有**启用的账号**:判定的优先级是
// no_accounts → 无余额端点 → usable → unknown/exhausted
// 我第一版在这条用例里忘了加账号,于是命中 no_accounts(优先级更高,
// 那是**正确**行为),把正确的实现判成了错。
h.addAccountOn("minimax", "m1")
// minimax 未声明 balance 端点
got := h.reasonOn("minimax")
if got["reason"] != "unknown" {
t.Fatalf("无余额端点时应报 unknown(无法判定 ≠ 用尽),实得 %v:%v",
got["reason"], got["detail"])
}
if d, _ := got["detail"].(string); !strings.Contains(d, "无法判定") {
t.Fatalf("说明必须点明无法判定,实得 %q", d)
}
})
}
// 锁定永久积分时,只剩永久积分的账号不可用,且必须报 **locked** 而不是 exhausted
// (用户动作不同:解锁 vs 等重置)。
func TestCreditsPoolReasonReportsLockedNotExhausted(t *testing.T) {
h := newPoolReasonHarness(t)
h.addAccount("a1")
// total_fn 取包列表:只有永久积分(到期未知)
h.serveBalance(0, 50)
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": true}); err != nil {
t.Fatal(err)
}
got := h.reason()
if got["reason"] != "locked" {
t.Fatalf("锁定且只剩永久积分时应报 locked(用户可解锁),实得 %v:%v",
got["reason"], got["detail"])
}
// 解锁后同一份余额应为 usable(证明 locked 不是"用尽")
if _, err := h.ps.Call("channels", "__set_lock_permanent_probe",
map[string]interface{}{"lock": false}); err != nil {
t.Fatal(err)
}
h.serveBalance(0, 50)
if got2 := h.reason(); got2["reason"] != "usable" {
t.Fatalf("解锁后同一份余额应可用(证明 locked ≠ exhausted),实得 %v", got2["reason"])
}
}
// ── harness ──
type poolReasonHarness struct {
t *testing.T
ps *lua.Plugins
ts *httptest.Server
body atomic.Value
fail atomic.Bool
hasAcct atomic.Bool
}
func newPoolReasonHarness(t *testing.T) *poolReasonHarness {
t.Helper()
h := &poolReasonHarness{t: t}
// buddy 的真实形态:包列表嵌在 data.Response.Data.Accounts
h.body.Store(buddyPkgBody(0, 0))
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if h.fail.Load() {
w.WriteHeader(500)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, h.body.Load().(string))
}))
t.Cleanup(h.ts.Close)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
h.ps = ps
// buddy 的余额端点指到假上游,并让 total_fn 走包列表分桶
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "buddy",
"balance": map[string]interface{}{"url": h.ts.URL},
}); err != nil {
t.Logf("set_endpoints(balance) 未支持: %v", err)
}
return h
}
func (h *poolReasonHarness) addAccount(id string) {
h.t.Helper()
h.addAccountOn("buddy", id)
}
// addAccountOn:往指定平台加账号(我第一版把它写死成 buddy,导致
// "平台无余额端点"那条用例其实没有该平台的账号,命中了优先级更高的
// no_accounts —— 判据写错而非实现错)。
func (h *poolReasonHarness) addAccountOn(platform, id string) {
h.t.Helper()
if _, err := h.ps.Call("channels", "account_add", map[string]interface{}{
"platform": platform, "account_id": id,
"credential": `{"access_token":"AT","uid":"u1"}`,
}); err != nil {
h.t.Fatalf("account_add(%s): %v", platform, err)
}
h.hasAcct.Store(true)
}
// serveBalance 直接给"临时/永久"两个桶的金额(内部拼成 buddy 的真实包列表)。
func (h *poolReasonHarness) serveBalance(expiring, permanent int) {
h.fail.Store(false)
h.body.Store(buddyPkgBody(expiring, permanent))
}
// buddyPkgBody 拼一份 buddy 真实形态的余额响应:
// 包列表在 data.Response.Data.Accounts,每包有 Status / CycleCapacityRemain /
// DeductionEndTime(毫秒)。到期时间落在窗口内 → 临时桶;未知(0)→ 永久桶。
func buddyPkgBody(expiring, permanent int) string {
pkgs := []string{}
if expiring > 0 {
// 3 天到期(< 15 天窗口)
endMs := (time.Now().Unix() + 3*86400) * 1000
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":%d}`, expiring, endMs))
}
if permanent > 0 {
// DeductionEndTime=0(未知)⇒ 永久桶
pkgs = append(pkgs, fmt.Sprintf(
`{"Status":1,"CycleCapacityRemain":%d,"DeductionEndTime":0}`, permanent))
}
return `{"data":{"Response":{"Data":{"Accounts":[` + strings.Join(pkgs, ",") + `]}}}}`
}
func (h *poolReasonHarness) serveBalanceError(code int) {
h.fail.Store(true)
}
func (h *poolReasonHarness) reason() map[string]interface{} {
h.t.Helper()
return h.reasonOn("buddy")
}
func (h *poolReasonHarness) reasonOn(platform string) map[string]interface{} {
h.t.Helper()
res, err := h.ps.Call("channels", "credits_pool_reason",
map[string]interface{}{"platform": platform})
if err != nil {
h.t.Fatalf("credits_pool_reason: %v", err)
}
return jsonOf(h.t, res)
}

View File

@ -0,0 +1,230 @@
package core
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 的 batch 模型目录解析:两个**真实缺陷**必须在移植时一起带上
// (参考 parseTraeBatchModelList 的注释记录了它们)。
//
// ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
//
// 22 个 function 里有一半在本插件推理端点下不可调用(实测 chat→4023
// model unknown、builder→4001、inline_chat→3003)。旧实现只把白名单用于
// "同模型择优",于是"目录声明什么就发什么" ⇒ 用户选中那些模型必然失败,
// 且错误指向**模型**,极易被误判成"这个模型坏了"。
//
// ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
//
// 旧实现无条件"后覆盖前",假设(后面的配置更全)与真实数据**相反** ——
// 上游把空档位的 solo_work_lite 排在最后,实测 13 个模型因此丢掉
// reasoning_effort_config,全部显示"未提供推理等级"。
func TestTraeBatchModelCatalogAdmissionAndReasoningProvenance(t *testing.T) {
var mu sync.Mutex
var gotBody string
h := newTraeCatalogHarness(t, func(body string) {
mu.Lock()
gotBody = body
mu.Unlock()
})
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
// ① 白名单准入:不可调用通道里的模型必须**被丢弃**
if strings.Contains(joined, "only-in-bad-channel") {
t.Fatalf("不可调用通道(chat/builder/inline_chat)里的模型必须整组丢弃,"+
"否则用户选中它必然失败且错误指向模型:%v", ids)
}
// 但通用通道里的模型要在
if !strings.Contains(joined, "glm-5.2") {
t.Fatalf("可调用通道里的模型不该被丢:%v", ids)
}
// ② 空档位不得覆盖有档位:同一模型在 solo_agent(有档位)与
// solo_work_lite(空档位,**排在后面**)都出现 ⇒ 必须保留有档位那条。
// 判据看**报出的档位**,不是"哪条被选中"这类内部信号。
if !h.modelHasReasoningOptions("glm-5.2") {
t.Fatalf("glm-5.2 的思考档位被空档位条目覆盖了 —— 上游把空档位的"+
"solo_work_lite 排在最后,无条件后覆盖前会让模型显示"+
"「未提供推理等级」(issue IKI7WT/IKILR7):%v", ids)
}
// ③ 请求体必须传**全部 22 个 function**(与真实 IDE 一致)——
// 只传聊天通道会让非聊天通道条目在响应里位置错乱甚至被跳过。
mu.Lock()
b := gotBody
mu.Unlock()
if b == "" {
t.Fatal("未捕获到目录请求体")
}
for _, want := range []string{"ui_builder_v2", "inline_chat", "system_diagnosis", "show_custom_model"} {
if !strings.Contains(b, want) {
t.Fatalf("目录请求体必须与真实 IDE 一致传全部 function(缺 %q):%s", want, b)
}
}
}
// 四条硬性过滤:usage / config_switch / is_invisible_to_user / 必须有 function。
func TestTraeBatchModelCatalogHardFilters(t *testing.T) {
h := newTraeCatalogHarness(t, nil)
ids := catalogIDs(t, h, "trae")
joined := strings.Join(ids, ",")
for _, bad := range []string{"summary-model", "disabled-model", "hidden-model", "custom-flag-model"} {
if strings.Contains(joined, bad) {
t.Errorf("%s 应被硬性过滤掉(usage/switch/invisible):%v", bad, ids)
}
}
// ⚠ 判据是**标志的值**,不是模型名:名字带 custom_model_ 前缀但标志为 false
// 的条目必须保留(参考实测:曾据此把 qwen3.8-flash 误记为应剔除,它其实可用)。
if !strings.Contains(joined, "custom_model_prefix_but_ok") {
t.Errorf("is_custom_model=false 的条目必须保留(判据是标志值而非模型名):%v", ids)
}
// ⚠ 我第一版把 no-function-model 放进 solo_agent 的 config_info_list 里,
// 却断言它应被丢弃 —— 那是**夹具写错**:它在合法组内,保留才是对的。
// 「未声明 function」指的是**整个 function_configs 组**没有 function 字段。
if !strings.Contains(joined, "no-function-model") {
t.Errorf("合法组内的条目不该被丢:%v", ids)
}
t.Logf("✓ 过滤后目录: %v", ids)
}
// ── harness:假上游返回一份 batch_get_detail_param 形态的目录 ──
type traeCatalogHarness struct {
t *testing.T
ps *lua.Plugins
}
func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarness {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
if onBody != nil {
onBody(string(b))
}
w.Header().Set("Content-Type", "application/json")
// 三个通道:
// solo_agent(**可调用**,白名单内)—— glm-5.2 **有**档位
// solo_work_lite(白名单内,**排在后面**)—— glm-5.2 **空档位**
// inline_chat(**白名单外**)—— 只列出 only-in-bad-channel
fmt.Fprint(w, `{"function_configs":[`+
// ① solo_agent:glm-5.2 有档位;另有三个必被硬性过滤的条目
`{"function":"solo_agent","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true,`+
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}},`+
`{"config_name":"summary-model","usage":"summary","config_switch":true},`+
`{"config_name":"disabled-model","usage":"chat_completion","config_switch":false},`+
`{"config_name":"hidden-model","usage":"chat_completion","config_switch":true,"is_invisible_to_user":true},`+
`{"config_name":"no-function-model","usage":"chat_completion","config_switch":true},`+
// is_custom_model=true ⇒ 上游以流内 4001 拒绝("仅可见但不可调用")
`{"config_name":"custom-flag-model","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":true}},`+
// 对照组:名字带 custom_model_ 前缀但**标志为 false** ⇒ 必须**保留**
// (参考点名:判据是标志的值,不是模型名)
`{"config_name":"custom_model_prefix_but_ok","usage":"chat_completion","config_switch":true,`+
`"display_config":{"is_custom_model":false}}`+
`]},`+
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置
`{"function":"solo_work_lite","config_info_list":[`+
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true}`+
`]},`+
// ③ 白名单外通道:整组必须被丢弃
`{"function":"inline_chat","config_info_list":[`+
`{"config_name":"only-in-bad-channel","usage":"chat_completion","config_switch":true}`+
`]}`+
`]}`)
}))
t.Cleanup(ts.Close)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
h := &traeCatalogHarness{t: t, ps: ps}
// 把目录端点指到假上游
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "trae",
"models_remote": map[string]interface{}{"endpoint0": ts.URL},
}); err != nil {
t.Fatal(err)
}
// 塞一个 TRAE 账号(目录请求需要凭据)
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "trae",
"account_id": "cat-trae",
"credential": `{"access_token":"AT","uid":"u1","refresh_token":"RT"}`,
}); err != nil {
t.Fatal(err)
}
return h
}
func catalogIDs(t *testing.T, h *traeCatalogHarness, platform string) []string {
t.Helper()
if _, err := h.ps.Call("channels", "model_refresh",
map[string]interface{}{"platform": platform}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": platform})
if err != nil {
t.Fatal(err)
}
j := jsonOf(t, res)
var out []string
arr, _ := j["models"].([]interface{})
for _, e := range arr {
if m, ok := e.(map[string]interface{}); ok {
if id, ok := m["id"].(string); ok {
out = append(out, id)
}
}
}
return out
}
// modelHasReasoningOptions 查某模型是否被报出思考档位(走 model_list 的字段)。
func (h *traeCatalogHarness) modelHasReasoningOptions(modelID string) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
if err != nil {
h.t.Fatal(err)
}
j := jsonOf(h.t, res)
arr, _ := j["models"].([]interface{})
for _, e := range arr {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
if m["id"] != modelID {
continue
}
// 档位的真实出口:model_list 把远端目录解出的档位放在
// `remote.efforts`(并镜像到顶层 efforts),另有 reasoning=true。
for _, k := range []string{"efforts", "reasoning_options", "reasoningOptions"} {
if v, ok := m[k].([]interface{}); ok && len(v) > 0 {
return true
}
}
if rm, ok := m["remote"].(map[string]interface{}); ok {
if v, ok := rm["efforts"].([]interface{}); ok && len(v) > 0 {
return true
}
}
}
return false
}

View File

@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
-- 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_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", "__zcode_diag_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", "__zcode_diag_probe", "__trae_entry_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" }
@ -128,6 +128,41 @@ local PLATFORMS = {
},
},
hosts = { "www.trae.cn", "api.trae.com.cn", "api.trae.cn", "trae-api-cn.mchost.guru" },
-- ★ 远端模型目录:POST /api/ide/v1/batch_get_detail_param(通过 models_remote)。
--
-- 为什么要有它:**只有这个端点能告诉我们每个模型的真实元数据** ——
-- 上下文窗口、输出上限、是否支持图像/工具响应图像、以及**思考档位**。
-- 硬编码的 models 兜底表只有名字,于是 UI 上所有模型都显示"未提供推理等级",
-- 且多模态能力无从得知。
--
-- ⚠ 端点选择:**必须用 batch**(多通道)而不是单通道 get_detail_param。
-- 真实 CN IDE 用 batch 一次查 22 个 function,每个 function 各自一套目录;
-- 只传聊天通道会让非聊天通道的条目在响应里**位置错乱甚至被跳过**(参考实测)。
--
-- ⚠ 请求体必须传**全部 22 个 function**(与真实 IDE 一致),不是只传我们关心的
-- 那几个 —— 理由同上:响应分组与请求顺序相关。解析侧再用白名单做**准入**。
models_remote = {
endpoints = { {
url = "https://trae-api-cn.mchost.guru/api/ide/v1/batch_get_detail_param",
method = "POST",
body = '{"functions":["ui_builder_v2","solo_coder","chat_v3","solo_builder",'
.. '"builder_v3","builder","chat","inline_chat","git_ai",'
.. '"custom_agent_generation","utils","code_reviewer","code_review_summary",'
.. '"solo_agent","solo_agent_remote","solo_work_remote","solo_agent_lite",'
.. '"solo_work_lite","solo_design_lite","solo_design_remote","multimodal",'
.. '"system_diagnosis"],"agent_type":"",'
.. '"current_config_info":{"config_name":"","is_custom_model":false},'
.. '"mode_type":0,"access_type":0,"ab_force_vids":"",'
.. '"ab_autotest_advanced_mode":0,"show_custom_model":true}',
-- ★ 鉴权方案是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
-- 设备身份),**不能只发 Bearer** —— 参考注释点明简化头实测失败。
-- 走 sign="trae_solo"(复用平台既有的 traeCheckinHeaders 实现,
-- 与 codearts 的 ep.sign 同一模式,不另写一套头部构造)。
sign = "trae_solo",
} },
auth = "none", -- 鉴权头由 headers_fn 负责
parse = "trae_batch",
},
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
base_url = "https://trae-api-cn.mchost.guru",
@ -3139,6 +3174,219 @@ local function parseRemoteModels(kind, body, provider)
end
end
elseif kind == "trae_batch" then
-- ★ TRAE 的 batch_get_detail_param(多通道模型目录)。
--
-- 参考实现(parseTraeBatchModelList)记录了**三个真实缺陷**,移植时必须一起
-- 带上,否则等于把已修好的 bug 再引进一遍:
--
-- ① **通道白名单是「准入」而不只是「排序」**(issue IKJOZ7):
-- 22 个 function 里有一半在本插件推理端点下**不可调用**
-- (实测 chat→4023 model unknown、builder→4001、inline_chat→3003)。
-- 旧实现只把白名单用于"同模型择优",于是"目录声明了什么就发什么" ——
-- 用户选中那些模型必然失败,而错误文案指向**模型**,极易被误判成
-- "这个模型坏了"。现在不在表内的通道**整组丢弃**。
--
-- ② **空档位不得覆盖有档位**(issue IKI7WT/IKILR7):
-- 旧实现无条件"后面的覆盖前面的",其假设(后面的配置更全)与真实数据
-- **相反** —— 上游把空档位的 solo_work_lite/solo_design_remote 排在最后,
-- 实测 13 个模型因此丢掉 reasoning_effort_config,全部显示"未提供推理等级"。
--
-- ③ **is_custom_model 是「仅可见但不可调用」的权威判据**:
-- 该标志为 true 的条目被上游以流内 event:error + 4001 拒绝。
-- ⚠ 判据是**标志的值**,不是模型名 —— 参考点名这个标志会随服务端下发变化
-- (某次快照里 5 个条目全部失效、全目录 true 的条目数为 0)。
--
-- 另有硬性过滤:usage 必须 chat_completion、config_switch 不得 false、
-- is_invisible_to_user 不得 true。
local TRAE_CHANNELS = {
"solo_agent", "solo_work_lite", "solo_agent_remote", "solo_work_remote",
"solo_agent_lite",
"chat_v3", "builder_v3", "solo_coder", "solo_design_lite",
"solo_design_remote", "git_ai", "code_reviewer", "code_review_summary",
"multimodal", "system_diagnosis",
}
local rankOf = {}
for i, c in ipairs(TRAE_CHANNELS) do rankOf[c] = i end
-- 取布尔字段(两种大小写)
local function rbool(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return v
end
local function rstr(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return type(v) == "string" and v or nil
end
local function rnum(t, a, b)
local v = t[a]
if v == nil and b then v = t[b] end
return tonumber(v)
end
-- declaresEffort(entry):该条目**能否真正声明出**思考档位。
-- ⚠ 必须与适配器的档位判定完全一致(配置存在 + 未显式 support_thinking:false
-- + options 非空)。只判"配置存在"不够:{support_thinking:false, options:[]}
-- 与 {...options:['high']} 都"存在配置",但前者在 UI 上仍显示"未提供推理等级"
-- —— 按"存在即优先"合并等于没修。
local function declaresEffort(e)
-- ⚠ 字段名必须与 parseEntry 存的一致(e.reasoning)。写成 e.reasoning_effort_config
-- 会让整个判定**恒为 false** ⇒ 规则①永不生效 ⇒ 空档位照样覆盖有档位
-- (正是该规则要防的缺陷;判据当场抓到)。
local rc = e.reasoning
if type(rc) ~= "table" then return false end
local st = rbool(rc, "support_thinking", "supportThinking")
if st == false then return false end
local opts = rc.options or rc.Options
return type(opts) == "table" and #opts > 0
end
-- parseEntry(it, channel):把一条 config_info_list 条目解成我们关心的字段。
local function parseEntry(it, chan)
local id = rstr(it, "config_name", "ConfigName")
if not id or id == "" then return nil end
local disp = it.display_config or it.DisplayConfig
if type(disp) ~= "table" then disp = {} end
local e = {
id = id,
name = rstr(disp, "display_name", "displayName") or id,
chan = chan,
usage = rstr(it, "usage", "Usage"),
enabled = rbool(it, "config_switch", "ConfigSwitch"),
hidden = rbool(it, "is_invisible_to_user", "IsInvisibleToUser"),
custom = rbool(disp, "is_custom_model", "IsCustomModel"),
reasoning = it.reasoning_effort_config or it.reasoningEffortConfig,
}
-- ⚠ contextWindow 取 `dev` 而非 `max`:真实条目形如 {dev:200000, max:1000000},
-- 而 max 需开启 display_config.max_mode 才可用(我们不实现该开关)。
-- 采信 max 会让上层以为有 1M 窗口、实际请求被上游拒。
local cw = it.context_window_tokens or it.contextWindowTokens
if type(cw) == "table" then
e.contextWindow = rnum(cw, "dev", "Dev")
end
-- 输出上限:model_detail_list[].max_tokens(取首条的 max_tokens)
local mdl = it.model_detail_list or it.modelDetailList
if type(mdl) == "table" and type(mdl[1]) == "table" then
e.maxOutputTokens = rnum(mdl[1], "max_tokens", "maxTokens")
end
-- 计费:display_contact_config 是 **JSON 字符串**(需二次解析)
local dcc = it.display_contact_config or it.DisplayContactConfig
if type(dcc) == "string" and dcc ~= "" then
local okj, cfg = pcall(json.decode, dcc)
if okj and type(cfg) == "table" then
local rate = cfg.consumption_rate or cfg.ConsumptionRate
if type(rate) == "table" then
local enable = rbool(rate, "enable", "Enable")
if enable ~= false then
local data = rate.data or rate.Data
if type(data) == "table" then
local v = rnum(data, "rate", "Rate")
if v and v >= 0 then e.creditsRate = v end
end
end
end
-- 活动折扣:**不能只看 enable** —— 实测 enable=true 但当前无折扣的形态
-- 形如 {type:"none", before:0.13, after:0.13, discount:100}
-- (discount=100 表示无折扣,before==after)。照显会让用户以为有活动。
local disc = cfg.activity_discount or cfg.ActivityDiscount
if type(disc) == "table" and rbool(disc, "enable", "Enable") ~= false then
local ddata = disc.data or disc.Data
if type(ddata) == "table" then
local cur = ddata.current or ddata.Current
if type(cur) == "table" then
local dtype = (rstr(cur, "discount_type", "discountType") or "")
dtype = dtype:lower():gsub("^%s+", ""):gsub("%s+$", "")
if dtype ~= "" and dtype ~= "none" then
local before = rnum(cur, "before_consumption_rate", "beforeConsumptionRate")
local after = rnum(cur, "consumption_rate", "consumptionRate")
-- 必须是真的降价:before 有值、为正、且严格大于 after
if before and before > 0 and (not after or before > after) then
-- 截止时间:data 下任意带 end_at 的子对象
local endsAt
for _, v in pairs(ddata) do
if type(v) == "table" then
local endv = rnum(v, "end_at", "endAt")
if endv and endv > 0 then endsAt = endv break end
end
end
local nowS = os.time and os.time() or 0
if not endsAt or endsAt > nowS then
e.origCreditsRate = before
e.discountEndsAt = endsAt
end
end
end
end
end
end
end
end
return e
end
local groups = rec.function_configs or rec.functionConfigs
if type(groups) == "table" then
local byId, chosenRank, order = {}, {}, {}
for _, g in ipairs(groups) do
if type(g) == "table" then
local chan = rstr(g, "function", "Function")
-- 准入:未声明 function 或不在白名单 → 整组丢弃
if chan and rankOf[chan] then
local list = g.config_info_list or g.ConfigInfoList
if type(list) == "table" then
for _, it in ipairs(list) do
if type(it) == "table" then
local e = parseEntry(it, chan)
if e then
-- 硬性过滤(不设开关)
local ok = true
if e.usage ~= nil and tostring(e.usage) ~= "chat_completion" then ok = false end
if e.enabled == false then ok = false end
if e.hidden == true then ok = false end
-- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝)
if e.custom == true then ok = false end
if ok then
local inc = byId[e.id]
if inc == nil then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
order[#order + 1] = e.id
else
local incEff = declaresEffort(inc)
local candEff = declaresEffort(e)
local take = true
if incEff and not candEff then
take = false -- 规则①:空档位不得覆盖有档位
elseif incEff and candEff then
-- 规则②:两侧都有档位 → 取通道优先级更靠前的
take = rankOf[chan] < (chosenRank[e.id] or 0)
end
if take then
byId[e.id] = e
chosenRank[e.id] = rankOf[chan]
end
end
end
end
end
end
end
end
end
end
for _, id in ipairs(order) do
out[#out + 1] = id
-- 元数据另存(model_list 用它报出上下文窗口/档位/倍率):
-- ⚠ 只报 id 会把参考能提供的元数据全部丢掉(档位信息在解析阶段就没了,
-- 上层只能显示"未提供推理等级")。
if not plugin.state.modelMeta then plugin.state.modelMeta = {} end
plugin.state.modelMeta[provider] = plugin.state.modelMeta[provider] or {}
plugin.state.modelMeta[provider][id] = byId[id]
end
end
elseif kind == "zcode_configs" then
-- 照参考 fetchZcodeModels:字段是 data.builtinModels,且它是**对象**
-- (键为序号 "0"/"1"),用 Array.isArray 判定会得到 0 个模型的假阴性。
@ -3208,6 +3456,10 @@ local function refreshRemoteModels(provider)
local url = ep.url
if ep.query then url = url .. "?" .. ep.query end
local headers = { ["Accept"] = "application/json" }
-- ★ ep.body:POST 型目录端点(如 trae 的 batch_get_detail_param 必须 POST +
-- 指定请求体)。缺失时按 GET。
local method = ep.method or "GET"
if ep.body then headers["Content-Type"] = "application/json" end
local useAuth = ep.url_auth ~= false and mr.auth ~= "none"
if useAuth and tok and tok ~= "" then
headers["Authorization"] = "Bearer " .. tok
@ -3221,7 +3473,38 @@ local function refreshRemoteModels(provider)
-- 不是 Bearer。ep.sign = "codearts" 时用平台签名函数重建整个请求
-- (签名覆盖 host/x-sdk-date/x-sdk-content-sha256/x-security-token 等头,
-- 故不能「先拼头再签名」—— 必须由签名函数产出最终请求)。
local req = { url = url, method = ep.method or "GET", headers = headers }
local req = { url = url, method = method, headers = headers }
if ep.body then req.body = ep.body end
if ep.sign == "trae_solo" then
-- TRAE 的鉴权是 Cloud-IDE-JWT + 约 20 个客户端头(含按 user_id 派生的
-- 设备身份)—— 不能只发 Bearer。这里复用**平台既有的** traeCheckinHeaders
-- 实现(它已经过实测:简化头会被拒),不另写一套头部构造,
-- 否则“chat 能通、目录 401”这类差异极难查。
local cred
for _, a in ipairs(poolList(provider)) do
if a.enabled ~= false then
local raw = __llmsproxy_secrets.get(a.credentialRef)
local okj, cj = pcall(json.decode, raw or "")
if okj and type(cj) == "table" and cj.access_token then
cred = cj break
end
end
end
if not cred then
lastErr = "无可用 TRAE 凭据"
elseif type(traeCheckinHeaders) ~= "function" then
lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)"
else
local uid = cred.uid or ""
local h, herr = traeCheckinHeaders(plat, cred, uid)
if herr then
lastErr = tostring(herr)
else
-- 融平台头(后者覆盖前面的 Accept,但保留 Authorization 等)
for k, v in pairs(h or {}) do req.headers[k] = v end
end
end
end
if ep.sign == "codearts" then
local cred
for _, a in ipairs(poolList(provider)) do
@ -4874,6 +5157,7 @@ end
-- 改成 local 后这个隐式依赖就断了 —— 表现为
-- attempt to call global 'traeCallback' (a nil value)。
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
local traeCheckinHeaders, traeFixNickname
function plugin.poll_login(req)
@ -6321,6 +6605,8 @@ plugin.http = {
roles = { "admin" }, description = "自动签到开关读写" },
{ path = "channels/credits/status", method = "POST", handler = "credits_status",
roles = { "admin" }, description = "逐账号签到状态" },
{ path = "channels/credits/pool_reason", method = "POST", handler = "credits_pool_reason",
roles = { "admin" }, description = "为什么没有可用账号(区分用尽/查不到)" },
{ path = "channels/credits/balances", method = "POST", handler = "credits_balances",
roles = { "admin" }, description = "逐账号余额" },
{ path = "channels/credits/permanent_lock", method = "POST", handler = "credits_permanent_lock",
@ -6828,6 +7114,40 @@ function plugin.model_list(req)
toolCall = cap.toolCall,
}
end
-- ★ 远端目录解析出的元数据(如 trae 的上下文窗口/输出上限/思考档位/倍率)。
-- 之前目录只报 id ⇒ 参考能提供的这些字段全丢,UI 只能显示"未提供推理等级"。
-- 能力表(models.dev)没给的值用远端目录补(远端是**权威**:它就是这个
-- 通道实际可用的配置)。两者都有时不覆盖能力表(后者是跨平台归一后的口径)。
local rm = plugin.state.modelMeta and plugin.state.modelMeta[plat.id]
and plugin.state.modelMeta[plat.id][m]
if rm then
entry.remote = {}
if rm.contextWindow then
entry.remote.contextWindow = rm.contextWindow
if not (cap and cap.contextWindow) then entry.contextWindow = rm.contextWindow end
end
if rm.maxOutputTokens then
entry.remote.maxOutputTokens = rm.maxOutputTokens
if not (cap and cap.maxOutputTokens) then entry.maxOutputTokens = rm.maxOutputTokens end
end
-- ⚠ `function` 是 Lua 关键字,必须用中括号键(本文件第 3 次踩)。
if rm.chan then entry.remote["function"] = rm.chan end
if rm.creditsRate then entry.remote.creditsRate = rm.creditsRate end
if rm.origCreditsRate then entry.remote.origCreditsRate = rm.origCreditsRate end
if rm.discountEndsAt then entry.remote.discountEndsAt = rm.discountEndsAt end
-- 思考档位:从远端条目解出(列表空/显式 support_thinking:false 则视为无)
local rc = rm.reasoning
if type(rc) == "table" then
local st = rc.support_thinking
if st == nil then st = rc.supportThinking end
local opts = rc.options or rc.Options
if st ~= false and type(opts) == "table" and #opts > 0 then
entry.remote.efforts = opts
if not (cap and cap.efforts) then entry.efforts = opts end
entry.reasoning = true
end
end
end
out[#out+1] = entry
end
end
@ -7111,6 +7431,96 @@ function plugin.credits_status(req)
return { status = 200, json = { ok = true, accounts = out } }
end
-- credits_pool_reason:为什么这个平台**没有可用账号**。
--
-- ## 为什么必须单独有这个出口(参考的同款修复,用户报障 2026-09-29)
-- 把「真的用尽」与「查不到」混成一句"额度都已用尽"会让**一次网络抖动伪装成
-- 钱花完了** —— 用户于是去解锁永久积分、或白等到明天,而号里其实有钱。
-- 两者的**正确动作完全相反**:
-- * exhausted(所有账号都查到了余额,且可用那类积分确实为 0)→ 等额度重置;
-- * unknown(至少一个账号查询失败,无法判定它是否真没额度)→ 稍后重试、
-- 或检查网络/凭据,**不要**去动永久积分锁定。
--
-- 返回 { reason = "exhausted"|"unknown"|"locked"|"no_accounts",
-- detail = 可读说明, errors = {...}, counts = {...} }。
function plugin.credits_pool_reason(req)
local args = bodyOf(req)
if args.platform == nil then args = req end
local plat = platformById(args and args.platform)
if not plat then return { status = 400, json = { error = "未知平台" } } end
local accounts = poolList(plat.id)
local enabled = 0
for _, a in ipairs(accounts) do
if a.enabled ~= false then enabled = enabled + 1 end
end
if enabled == 0 then
return { status = 200, json = {
ok = true, reason = "no_accounts",
detail = "该平台没有启用的账号" .. (#accounts > 0 and ("(有 " .. #accounts .. " 个被停用)") or ""),
counts = { total = #accounts, enabled = 0 } } }
end
-- 平台没有余额端点 ⇒ 无法判定,如实说"查不到"而不是"用尽"
if not plat.balance then
return { status = 200, json = {
ok = true, reason = "unknown",
detail = "该平台未声明余额端点,无法判定是否用尽(不要当成额度已用尽)",
counts = { total = #accounts, enabled = enabled } } }
end
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
local allowPermanent = not lockPermanent
local r = rankAccounts(plat.id)
local errors = {}
-- 逐账号收集查询失败原因(rankAccounts 只给档位,诊断要原因)
for _, a in ipairs(accounts) do
if a.enabled ~= false then
local raw = accountBalanceRaw(plat.id, a.id)
if raw == nil then errors[#errors + 1] = a.id end
end
end
if #r.usable > 0 then
return { status = 200, json = {
ok = true, reason = "usable", usable = #r.usable,
detail = "有 " .. #r.usable .. " 个可用账号",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = lockPermanent and true or false } }
end
-- ★ 无可用:区分「查不到」与「真的用尽」
if #errors > 0 then
return { status = 200, json = {
ok = true, reason = "unknown",
detail = "有 " .. #errors .. " 个账号的余额**查询失败**,无法判定它们是否真的没额度"
.. "(不要当成额度已用尽:可能是网络抖动;也不要去解锁永久积分)",
error_accounts = errors,
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = lockPermanent and true or false } }
end
if lockPermanent then
-- 锁定时只剩永久积分的账号视为不可用(照参考 tier=none 语义)
return { status = 200, json = {
ok = true, reason = "locked",
detail = "永久积分已锁定," .. tostring(r.permanent) .. " 个只剩永久积分的账号不可用"
.. "(如需使用可解锁;这不是「额度用尽」)",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = true } }
end
return { status = 200, json = {
ok = true, reason = "exhausted",
detail = "所有账号都查到了余额,且可用积分确实为 0(等额度重置)",
counts = { total = #accounts, enabled = enabled,
expiring = r.expiring, permanent = r.permanent, none = r.none },
locked = false } }
end
-- credits_balances:逐账号查余额(对齐参考 credits.balances;
-- 无 balance 端点的平台返回 {balance=null, error="平台未声明余额端点"},
-- 不编造数字 —— 与参考注释「不伪装成余额」同语义)
@ -10828,6 +11238,27 @@ function plugin.claims_capable(args)
} }
end
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
-- 没有它只能靠猜。
function plugin.__trae_entry_probe(args)
args = args or {}
local id = tostring(args.model or "")
local meta = plugin.state.modelMeta and plugin.state.modelMeta.trae
local e = meta and meta[id]
if not e then return { json = { error = "无该模型元数据" } } end
local rc = e.reasoning
local opts = type(rc) == "table" and (rc.options or rc.Options) or nil
return { json = {
id = id, chan = e.chan, usage = e.usage,
enabled = e.enabled, hidden = e.hidden, custom = e.custom,
has_reasoning = rc ~= nil,
n_options = type(opts) == "table" and #opts or 0,
support_thinking = type(rc) == "table"
and (rc.support_thinking or rc.supportThinking) or nil,
} }
end
-- plugin.__zcode_diag_probe:只给测试/诊断用 —— 暴露共用的 3012 判据模块。
--
-- 为什么需要它:该模块被**两条路径共用**(领取路径在插件主体、推理路径在