mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ae7d2c8be | |||
| 02c01582f1 | |||
| 6454b094bc | |||
| 771b95a133 | |||
| 441de434f5 | |||
| 9feef8f4cb | |||
| b5c89fd3d7 | |||
| 7f193a997d | |||
| 055705c134 | |||
| 401a7ead27 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.42",
|
||||
"version": "2.0.46",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.42",
|
||||
"version": "2.0.46",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "2.0.42",
|
||||
"version": "2.0.46",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
@ -90,4 +90,4 @@
|
||||
"asar": true
|
||||
},
|
||||
"homepage": "https://gitcode.com/JianFeeeee/ModelRouter"
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,7 +95,14 @@ type Source struct {
|
||||
// ProxyURL routes this source's HTTP(S) traffic through an explicit proxy
|
||||
// (e.g. http://127.0.0.1:7890). Empty = direct connection. Needed for
|
||||
// upstreams behind geo/IP blocks that only respond through a proxy.
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
ProxyURL string `yaml:"proxy_url" json:"proxy_url,omitempty"`
|
||||
// Retry is an OPTIONAL upstream retry policy. It exists for upstreams that
|
||||
// answer a business-level rejection with a status code the kernel would
|
||||
// otherwise surface to the client — e.g. a per-account concurrency cap that
|
||||
// clears by itself after tens of seconds. The kernel stays vendor-agnostic:
|
||||
// the matcher is pure DATA (status list + response-body substrings), so no
|
||||
// provider name ever appears in core.
|
||||
Retry *RetryPolicy `yaml:"retry" json:"retry,omitempty"`
|
||||
Meta map[string]interface{} `yaml:"meta" json:"meta,omitempty"`
|
||||
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
|
||||
MaxTokens int `yaml:"max_tokens" json:"max_tokens,omitempty"`
|
||||
@ -105,6 +112,63 @@ type Source struct {
|
||||
RPM int `yaml:"rpm" json:"rpm,omitempty"` // optional requests-per-minute cap (0 = unlimited)
|
||||
}
|
||||
|
||||
// RetryPolicy describes when a failed upstream attempt may be retried BEFORE
|
||||
// anything is returned to the client. All conditions are data:
|
||||
//
|
||||
// OnStatus - retry only when the HTTP status is one of these
|
||||
// OnBodyContains - ... AND the response body contains one of these substrings
|
||||
// (empty = status alone decides)
|
||||
// Delay - wait between attempts ("10s" etc.); parsed on demand
|
||||
// MaxAttempts - TOTAL attempts including the first (1 or 0 = no retry)
|
||||
//
|
||||
// Scope: the retry happens inside one gateway request, so a stream is only
|
||||
// retried before its first chunk reaches the client (the kernel already holds
|
||||
// the first chunk back), and a non-stream call is retried as a whole.
|
||||
type RetryPolicy struct {
|
||||
OnStatus []int `yaml:"on_status,omitempty" json:"on_status,omitempty"`
|
||||
OnBodyContains []string `yaml:"on_body_contains,omitempty" json:"on_body_contains,omitempty"`
|
||||
Delay string `yaml:"delay,omitempty" json:"delay,omitempty"`
|
||||
MaxAttempts int `yaml:"max_attempts,omitempty" json:"max_attempts,omitempty"`
|
||||
}
|
||||
|
||||
// ShouldRetry reports whether one failed attempt matches this policy.
|
||||
func (r *RetryPolicy) ShouldRetry(status int, body string) bool {
|
||||
if r == nil || r.MaxAttempts <= 1 {
|
||||
return false
|
||||
}
|
||||
statusOK := len(r.OnStatus) == 0
|
||||
for _, s := range r.OnStatus {
|
||||
if s == status {
|
||||
statusOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !statusOK {
|
||||
return false
|
||||
}
|
||||
if len(r.OnBodyContains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, sub := range r.OnBodyContains {
|
||||
if sub != "" && strings.Contains(body, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DelayDuration is the configured pause between attempts (default 10s).
|
||||
func (r *RetryPolicy) DelayDuration() time.Duration {
|
||||
if r == nil || r.Delay == "" {
|
||||
return 10 * time.Second
|
||||
}
|
||||
d, err := time.ParseDuration(r.Delay)
|
||||
if err != nil || d < 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Load reads and validates a config file. When the file does not exist yet a
|
||||
// default config is generated at that path first (first-run bootstrap), so a
|
||||
// fresh binary just works: `llmsproxy -config /path/to/config.yaml`.
|
||||
|
||||
59
internal/config/retry_policy_test.go
Normal file
59
internal/config/retry_policy_test.go
Normal file
@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShouldRetry 是纯数据判据:状态码列表 + 响应体子串。它决定"这次失败值不值得
|
||||
// 在网关内部等一会儿重试"。厂商知识绝不能进内核,所以判据必须能用一个表格测清楚。
|
||||
func TestRetryPolicyShouldRetry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{"nil policy", nil, 500, "anything", false},
|
||||
{"max_attempts=1 disables", &RetryPolicy{OnStatus: []int{400}, MaxAttempts: 1}, 400, "x", false},
|
||||
{"status matches, no body filter",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 429, "", true},
|
||||
{"status mismatch",
|
||||
&RetryPolicy{OnStatus: []int{429}, MaxAttempts: 3}, 400, "429", false},
|
||||
{"status + body match",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001041"}`, true},
|
||||
{"status matches but body does not",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{"TM.00001041"}, MaxAttempts: 3},
|
||||
400, `{"error_code":"TM.00001005"}`, false},
|
||||
{"empty substring never matches",
|
||||
&RetryPolicy{OnStatus: []int{400}, OnBodyContains: []string{""}, MaxAttempts: 3}, 400, "x", false},
|
||||
{"any of several body signals",
|
||||
&RetryPolicy{OnStatus: []int{400, 429}, OnBodyContains: []string{"TM.00001041", "TPM limit"}, MaxAttempts: 3},
|
||||
429, `{"message":"TPM limit exceeded"}`, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.ShouldRetry(tc.status, tc.body); got != tc.want {
|
||||
t.Errorf("%s: ShouldRetry(%d, %q) = %v, want %v", tc.name, tc.status, tc.body, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPolicyDelay(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
policy *RetryPolicy
|
||||
want time.Duration
|
||||
}{
|
||||
{"nil", nil, 10 * time.Second},
|
||||
{"explicit", &RetryPolicy{Delay: "250ms"}, 250 * time.Millisecond},
|
||||
{"garbage falls back", &RetryPolicy{Delay: "not-a-duration"}, 10 * time.Second},
|
||||
{"negative falls back", &RetryPolicy{Delay: "-5s"}, 10 * time.Second},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.policy.DelayDuration(); got != tc.want {
|
||||
t.Errorf("%s: DelayDuration() = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -68,6 +68,16 @@ func TestDeadModelGoneJudgement(t *testing.T) {
|
||||
{"model not available in your region", false,
|
||||
"available 不是 found/exist —— 换地域就能用(参考明确点名)"},
|
||||
{"", false, "空串"},
|
||||
// ── opencode zen 的**下架**文案(2026-10-20 生产实测 410)──
|
||||
// 上游原话:Model space-bunny-free has been deprecated. Use
|
||||
// minimax-m3.1-flash-free instead. 此前不识别 ⇒ 该模型一直留在源里,
|
||||
// 用户选中必 410("渠道不可用"的观感来源之一)。
|
||||
{"Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.",
|
||||
true, "opencode zen 410 下架文案"},
|
||||
{"model has been deprecated", true, "同族最短形态(无模型名)"},
|
||||
// 阴性:参数/字段被弃用 ≠ 模型下架(主语不是 Model)
|
||||
{"parameter temperature has been deprecated", false, "参数弃用不是模型下架"},
|
||||
{"the field max_tokens has been deprecated", false, "字段弃用不是模型下架"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
out, err := ps.Call("channels", "__is_model_gone_probe",
|
||||
|
||||
56
internal/core/channels_platforms_logged_in_test.go
Normal file
56
internal/core/channels_platforms_logged_in_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// `platforms` 的 logged_in 必须看**账号池**,不能只看旧单账号保险库键。
|
||||
//
|
||||
// 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):
|
||||
// `list_platforms` 当时写的是 `credentialOf("channels:" .. id)` —— 那是旧版
|
||||
// 单账号的键;而现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号)。
|
||||
// 于是**所有**通过账号池接入的平台都被判成未登录,UI 把渠道卡片渲染成"未接入",
|
||||
// 用户看到的正是"渠道不可用",而底层模型其实完全能用。
|
||||
//
|
||||
// 判据钉两层:池里的账号让 logged_in=true,且账号标识能被 UI 拿到。
|
||||
func TestPlatformsLoggedInFromAccountPool(t *testing.T) {
|
||||
_, ps := poolCore(t)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "codearts", "account_id": "acct-pool-1",
|
||||
"credential": `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`,
|
||||
"nickname": "pool-nick",
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
// list_platforms 是 HTTP 路由处理器(不在 plugin.exports 里),走探针取同一份数据
|
||||
m, err := ps.Call("channels", "__platforms_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("__platforms_probe: %v", err)
|
||||
}
|
||||
w, _ := m.(map[string]interface{})
|
||||
j, _ := w["json"].(map[string]interface{})
|
||||
arr, _ := j["platforms"].([]interface{})
|
||||
if len(arr) == 0 {
|
||||
t.Fatalf("platforms 不应为空")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
for _, raw := range arr {
|
||||
p, _ := raw.(map[string]interface{})
|
||||
if p["id"] == "codearts" {
|
||||
got = p
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("platforms 里找不到 codearts")
|
||||
}
|
||||
if got["logged_in"] != true {
|
||||
t.Fatalf("账号池里有凭据的账号,logged_in 必须为 true;实得 %#v", got)
|
||||
}
|
||||
if got["account_id"] != "acct-pool-1" {
|
||||
t.Errorf("account_id 应回池里的账号 id;实得 %#v", got["account_id"])
|
||||
}
|
||||
if got["account"] != "pool-nick" {
|
||||
t.Errorf("account 应回池里的昵称;实得 %#v", got["account"])
|
||||
}
|
||||
if s, _ := got["accounts"].(float64); s < 1 {
|
||||
t.Errorf("accounts 计数应 >=1;实得 %#v", got["accounts"])
|
||||
}
|
||||
}
|
||||
@ -120,23 +120,23 @@ func newTraeCatalogHarness(t *testing.T, onBody func(string)) *traeCatalogHarnes
|
||||
// ① 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"]}},`+
|
||||
`"reasoning_effort_config":{"support_thinking":true,"options":["high","low"]}}`+
|
||||
`]},`+
|
||||
// ② solo_work_lite 排在**后面**:glm-5.2 在这里**没有**档位配置。
|
||||
// 另三个"必被过滤"的条目也放这里 —— 生产里适配器恒发这条通道,
|
||||
// 硬性过滤的判据必须在这条通道上被验证到(放 solo_agent 会因为
|
||||
// admit_functions 策略先行剔除,测试就测不到过滤逻辑了)。
|
||||
`{"function":"solo_work_lite","config_info_list":[`+
|
||||
`{"config_name":"glm-5.2","usage":"chat_completion","config_switch":true},`+
|
||||
`{"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}`+
|
||||
|
||||
71
internal/core/channels_trae_headers_test.go
Normal file
71
internal/core/channels_trae_headers_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TRAE 的**两套头不能混用** —— 这是 2026-10-20 那次"目录永远拉不到"的真因。
|
||||
//
|
||||
// 事故链:模型目录端点在 `trae-api-cn.mchost.guru`(IDE 面),需要 IDE 头族
|
||||
// (Trae UA + X-App-Id + X-Ide-Version* + 凭据里的 X-Machine-Id/X-Device-Id);
|
||||
// 而 `api.trae.cn` 的签到/积分端点需要另一套(VSCode UA + X-Market-* +
|
||||
// X-Lscbd-* + 派生 market device id)。插件当时"复用签到头"去拉目录,上游对
|
||||
// 22 个 function 全部回 `error_code 2001 Config detail for function: X not found`
|
||||
// (HTTP 200!),于是远端目录恒空、只能退回兜底表 —— 用户看到的却是
|
||||
// "trae 模型不正确 / 渠道不可用",与真因隔了整整一层协议。
|
||||
//
|
||||
// 这个测试把两套头的**契约**钉死:键集合必须互不越界。它不联网,因此能
|
||||
// 在 CI 里稳定复现"用错头"这一类回归。
|
||||
func TestTraeHeadersKeptSeparate(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
out, err := ps.Call("channels", "__trae_headers_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
pick := func(field string) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
arr, _ := j[field].([]interface{})
|
||||
for _, raw := range arr {
|
||||
if s, ok := raw.(string); ok {
|
||||
set[s] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
ide, chk := pick("ide"), pick("checkin")
|
||||
if len(ide) == 0 || len(chk) == 0 {
|
||||
t.Fatalf("两套头都应有内容;实得 ide=%v checkin=%v", ide, chk)
|
||||
}
|
||||
// IDE 面(目录/对话):身份与版本头必须在
|
||||
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-App-Version-Code",
|
||||
"X-Machine-Id", "X-Device-Id", "Request-Traffic-Type"} {
|
||||
if !ide[k] {
|
||||
t.Errorf("IDE 头缺少 %q(目录端点会回 2001)", k)
|
||||
}
|
||||
}
|
||||
// IDE 面**不得**带 UG/积分的头
|
||||
for _, k := range []string{"X-Market-Client-Id", "X-Lscbd-Aid", "X-Market-User-Id"} {
|
||||
if ide[k] {
|
||||
t.Errorf("IDE 头不应包含 %q(那是签到头)", k)
|
||||
}
|
||||
}
|
||||
// 签到头:UG/积分标记必须在
|
||||
for _, k := range []string{"X-Market-Client-Id", "X-Market-User-Id", "X-Lscbd-Aid"} {
|
||||
if !chk[k] {
|
||||
t.Errorf("签到头缺少 %q", k)
|
||||
}
|
||||
}
|
||||
// 签到头不得冒用 IDE 的 App/机型身份
|
||||
for _, k := range []string{"X-App-Id", "X-Ide-Version", "X-Machine-Id"} {
|
||||
if chk[k] {
|
||||
t.Errorf("签到头不应包含 %q(那是 IDE 身份)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
111
internal/core/channels_trae_models_test.go
Normal file
111
internal/core/channels_trae_models_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package core
|
||||
|
||||
import "testing"
|
||||
|
||||
// TRAE 的模型选择器**不许**出现 `custom_model_*`。
|
||||
//
|
||||
// 真实缺陷(2026-10-20 用户报「trae 的模型不正确 / 渠道不可用」):
|
||||
// 这些 id 是 TRAE 的「自定义模型槽位」,上游目录里它们的权威判据是
|
||||
// `is_custom_model: true`(插件 trae_batch 解析器早已按这条准入剔除)。
|
||||
// 但**兜底表**把它们当普通模型列了出来 —— 而远端目录在部分账号下恒空
|
||||
// (企业租户账号实测:22 个 function 全回 error_code 2001 "Config detail
|
||||
// for function: X not found"),于是用户只能看到兜底表,选中这些槽位后
|
||||
// 上游回**空 content**(实测 custom_model_kimi / custom_model_claude /
|
||||
// custom_model_1M 均空),表现就是"模型坏了、渠道不可用"。
|
||||
//
|
||||
// 判据分两层:
|
||||
// - model_list(UI 选择器)不含它们;
|
||||
// - 交给内核的上游源里也不含它们(effectiveModels 走 models_unavailable)。
|
||||
func TestTraeCustomModelSlotsAreNotOffered(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
|
||||
out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "trae"})
|
||||
if err != nil {
|
||||
t.Fatalf("model_list: %v", err)
|
||||
}
|
||||
j, _ := out.(map[string]interface{})["json"].(map[string]interface{})
|
||||
models, _ := j["models"].([]interface{})
|
||||
if len(models) == 0 {
|
||||
t.Fatal("trae 应至少有一个可选模型(兜底表)")
|
||||
}
|
||||
for _, raw := range models {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
id, _ := m["id"].(string)
|
||||
if len(id) > 13 && id[:13] == "custom_model_" {
|
||||
t.Fatalf("model_list 不应包含自定义模型槽位 %q(选中后上游回空内容)", id)
|
||||
}
|
||||
}
|
||||
// 可调用模型必须还在(不能把整张表干掉换绿)
|
||||
found := false
|
||||
for _, raw := range models {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
if m["id"] == "glm-5.2" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("可调用的 glm-5.2 不应被一起藏掉;实得 %v", models)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游源的模型清单同样不含占位槽位(客户端按裸名就能调,藏 UI 不够)。
|
||||
func TestTraeSourceOmitsCustomModelSlots(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels",
|
||||
[]string{"add_source", "remove_source", "list_sources", "status"}, false)
|
||||
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
|
||||
"platform": "trae", "account_id": "trae-a",
|
||||
"credential": `{"access_token":"jwt","uid":"1","device_id":"d","machine_id":"m"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("account_add: %v", err)
|
||||
}
|
||||
ls, err := ps.Call("channels", "__list_sources_probe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("probe: %v", err)
|
||||
}
|
||||
j, _ := ls.(map[string]interface{})["json"].(map[string]interface{})
|
||||
arr, _ := j["sources"].([]interface{})
|
||||
haveTrae := false
|
||||
for _, raw := range arr {
|
||||
m, _ := raw.(map[string]interface{})
|
||||
if m["name"] == "channels-trae" {
|
||||
haveTrae = true
|
||||
}
|
||||
}
|
||||
if !haveTrae {
|
||||
t.Fatalf("trae 账号加好后应产出 channels-trae 源;实得 %v", arr)
|
||||
}
|
||||
// 源里的模型清单从内核真实源读(不是插件的 model_list)
|
||||
srcs := c.Sources()
|
||||
for _, s := range srcs {
|
||||
if s.Name != "channels-trae" {
|
||||
continue
|
||||
}
|
||||
for _, m := range s.Models {
|
||||
if len(m.ID) > 13 && m.ID[:13] == "custom_model_" {
|
||||
t.Fatalf("上游源不应包含自定义模型槽位 %q", m.ID)
|
||||
}
|
||||
}
|
||||
if len(s.Models) == 0 {
|
||||
t.Fatal("channels-trae 源应带模型清单")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -140,7 +140,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", "__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", "__chosen_account_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", "__trae_catalog_probe", "__trae_headers_probe", "__platforms_probe", "__list_sources_probe", "__clear_priorities_probe", "__resync_sources_probe", "__utf8_clip_probe", "__event_log_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__chosen_account_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" }
|
||||
|
||||
|
||||
|
||||
@ -197,7 +197,7 @@ local pickAccountForSource, accountBalanceRaw, balanceTier
|
||||
-- ⇒ 上游回「not able to authenticate you」;而同一账号的**签到领取是成功的**
|
||||
-- (claim_fn 那条路同样拿到 nil,但它内部有 Bearer 兜底且 claim 端点接受)。
|
||||
-- ⇒ 教训:Lua 的 local 只对**其后的**代码可见;闭包不是延迟绑定的魔法。
|
||||
local traeCheckinHeaders, traeFixNickname
|
||||
local traeCheckinHeaders, traeFixNickname, traeIdeHeaders
|
||||
local traeCallback, traeDeriveDeviceId15, traeDeriveMarketUserId, traeDeriveSessionId
|
||||
|
||||
local PLATFORMS = {
|
||||
@ -273,6 +273,20 @@ local PLATFORMS = {
|
||||
} },
|
||||
auth = "none", -- 鉴权头由 headers_fn 负责
|
||||
parse = "trae_batch",
|
||||
-- ★★ 只收**适配器真正会发的那条通道**里的模型。
|
||||
--
|
||||
-- 实测(2026-10-20,真实目录 22 个 function / 93 条去重模型):
|
||||
-- 同一个 config_name 在不同 function 里的**可用性不同**,而本插件的
|
||||
-- trae 适配器只会发 `solo_work_lite`(见 TRAE_FUNCTION)。把别的通道
|
||||
-- 的模型也列出来,用户选中后上游要么回**空内容**(kimi-k2.8-preview /
|
||||
-- deepseek-V3 / glm-4.7 / code-review-judge 实测皆空),要么**悄悄回落到
|
||||
-- 默认模型**(custom_model_gpt-5 自称 GPT-4、sagitta 自称 Claude ——
|
||||
-- 这正是"模型不正确"的真实来源)。
|
||||
--
|
||||
-- 收窄到 solo_work_lite 后:20 个模型,逐个实测都能返回内容且确实是
|
||||
-- 它自称的那个模型(glm-5.3 / kimi-k3 / minimax-m3.1-flash / qwen3.8-max /
|
||||
-- step-5-preview / deepseek-v4.1-flash …)。
|
||||
admit_functions = { "solo_work_lite" },
|
||||
},
|
||||
-- trae 非 OpenAI 兼容:端点是 SOLO 的 /api/agent/v3/llm_utils_chat,
|
||||
-- 载荷须转 config_name/function 格式(见 trae-adapter.ts:374)。
|
||||
@ -291,14 +305,31 @@ local PLATFORMS = {
|
||||
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
|
||||
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
|
||||
"kimi-k2.6", "minimax-m3", "qwen-3.7-plus", "sagitta", "aquila",
|
||||
},
|
||||
-- ★ custom_model_* 是 TRAE 的「自定义模型槽位」,**不是可调用的模型**:
|
||||
-- 上游目录里它们的判据是 `is_custom_model: true`(本插件的 trae_batch
|
||||
-- 解析器早就按这条**准入**剔除),但兜底表此前把它们当普通模型列出来 ——
|
||||
-- 于是远端目录取不到时(企业租户账号实测:22 个 function 全回
|
||||
-- `2001 Config detail ... not found` ⇒ 目录为空),用户在模型选择器里
|
||||
-- 能看到这些条目,选中后上游回空内容(实测 custom_model_kimi /
|
||||
-- custom_model_claude / custom_model_1M 均返回空 content)——
|
||||
-- 表现就是「模型不正确 / 渠道不可用」。
|
||||
-- 它们与内部子 agent 同性质:保留在目录里(排查可见),但不进可选列表。
|
||||
-- ★ 远端目录一旦可用,就**不再补兜底表**(参考语义:remote ?? fallback)。
|
||||
-- 兜底表里的名字是历史快照,其中有若干**上游已不存在**的条目
|
||||
-- (实测 sagitta 自称 Claude、aquila 自称 ChatGPT、glm-5 / DeepSeek-V4-Flash
|
||||
-- 在 solo_work_lite 通道里根本没有配置)—— 补进来 = 给用户一堆"点了回错模型"
|
||||
-- 的选项。仅在远端目录为空时才用兜底表(那才是它的用途)。
|
||||
models_fallback_no_supplement = true,
|
||||
models_hidden = {
|
||||
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
|
||||
},
|
||||
models_unavailable = {
|
||||
"custom_model_gemini", "custom_model_placeholder", "custom_model_1M_text",
|
||||
"custom_model_1M", "custom_model_kimi", "custom_model_claude",
|
||||
"custom_model_gpt-5", "custom_model_no-fc", "custom_model_deepseek_chat",
|
||||
"custom_model_deepseek_reasoner", "custom_model_deepseek_v4",
|
||||
},
|
||||
models_hidden = {
|
||||
"browser_use_subagent", "explore_sub_agent_v13", "explore_sub_agent_v2", "summary",
|
||||
},
|
||||
-- 每日签到:POST /trae/api/v2/ug/checkin_credits/claim body {} 到 UG 主机。
|
||||
checkin = {
|
||||
url = "https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim",
|
||||
@ -964,19 +995,27 @@ local PLATFORMS = {
|
||||
-- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free /
|
||||
-- glm-5.3-flash-free / minimax-m3.1-flash-free
|
||||
models = {
|
||||
"big-pickle", "beam-free", "longcat-2.5-preview-free",
|
||||
"big-pickle", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
|
||||
"nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free",
|
||||
"nemotron-3.5-lightning-free", "glm-5.3-flash-free",
|
||||
"minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
|
||||
"deepseek-v4-flash", "deepseek-v4.1-flash",
|
||||
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
|
||||
},
|
||||
models_free = {
|
||||
"big-pickle", "beam-free", "longcat-2.5-preview-free",
|
||||
"big-pickle", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
|
||||
"nemotron-3.5-lightning-free", "jev-1.13-free",
|
||||
"nemotron-3.5-lightning-free",
|
||||
"glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
|
||||
},
|
||||
-- ★ 目录里有、但**本协议下不可调用**的免费模型(2026-10-20 逐个实测):
|
||||
-- * jev-1.13-free → 400 "Model does not support this protocol."
|
||||
-- (它不是 OpenAI chat 协议,用 /v1/chat/completions 必失败)
|
||||
-- * beam-free → 200 但 content 为空、无 finish_reason(上游未真正产出)
|
||||
-- 二者若留在可选列表,用户选中后只会看到"空回复/报错" ⇒ 判为不可用,
|
||||
-- 既不进 UI 列表,也不进上游源(models_unavailable 与 hidden 的区别见
|
||||
-- effectiveModels 的注释)。
|
||||
models_unavailable = { "jev-1.13-free", "beam-free" },
|
||||
},
|
||||
|
||||
-- CodeArts(华为)—— 凭据 + SDK-HMAC-SHA256 签到(照参考 codearts-credits.ts)。
|
||||
@ -1007,6 +1046,24 @@ local PLATFORMS = {
|
||||
base_url = "https://snap-access.cn-north-4.myhuaweicloud.com",
|
||||
-- 本插件**自己装的**适配器(见 installCodeartsAdapter):华为云 AK/SK 签名
|
||||
adapter = "codearts",
|
||||
-- ★ 上游的"不可用"多数是**账号级限流**,约 1 分钟自愈,不是模型坏了:
|
||||
-- * 400 `TM.00001041: 并发会话数已达上限(3个)` —— 账户并发会话上限,
|
||||
-- 实测最后一个请求结束后约 44~60 秒释放;
|
||||
-- * 429 `TPM limit ... 500000 tokens per minute` —— 每分钟 token 配额。
|
||||
-- 内核原本**只在 429 上做冷却、不重试**,于是这类"等一会儿就好"的拒绝
|
||||
-- 直接冒到客户端(流式还表现为 `empty stream`,因为上游用 HTTP 200 +
|
||||
-- error 帧表达拒绝)。
|
||||
--
|
||||
-- 这里声明**声明式重试策略**(内核只按数据匹配,不认识 CodeArts):
|
||||
-- 命中即在网关内部每 10s 重试一次,最多 18 次(≈3 分钟),客户端只是
|
||||
-- 多等几秒,不会看到错误。流式只在**握手阶段**重试(首帧尚未下发),
|
||||
-- 不会重复输出。
|
||||
retry_policy = {
|
||||
on_status = { 400, 429 },
|
||||
on_body_contains = { "TM.00001041", "TPM limit", "并发会话数已达上限" },
|
||||
delay = "10s",
|
||||
max_attempts = 18,
|
||||
},
|
||||
-- 签名平台:源注册要交**完整凭据 JSON**(适配器自己解 AK/SK 签名),
|
||||
-- 不是裸 access token。见 syncAccountSource 的注释。
|
||||
credential_shape = "full",
|
||||
@ -3735,6 +3792,29 @@ local deadModelIds
|
||||
local function effectiveModels(provider)
|
||||
local plat = platformById(provider)
|
||||
if not plat then return {} end
|
||||
-- models_unavailable:**声明上就不可调用**的条目(不是"暂不显示")。
|
||||
-- 与 models_hidden 的区别:hidden 只是不进 UI 列表(内部子 agent 仍可被
|
||||
-- 显式调用),unavailable 连**上游源**都不进 —— 因为它们调用必然失败/回空,
|
||||
-- 进源只会让客户端选中后报错(trae 的 custom_model_* 槽位就是这类)。
|
||||
local unavailable = {}
|
||||
for _, m in ipairs(plat.models_unavailable or {}) do unavailable[m] = true end
|
||||
-- ★ admit_functions:只放行**本适配器真的会发**的通道里的模型。
|
||||
--
|
||||
-- 白名单(parse 里的 rankOf)只说明"该通道在本端点下可调用",不等于
|
||||
-- "适配器会发它"。trae 适配器恒发 solo_work_lite,别的通道的模型经我们
|
||||
-- 这条路发出去要么回空内容、要么被上游悄悄回落到默认模型(实测
|
||||
-- kimi-k2.8-preview / deepseek-V3 空回,sagitta 自称 Claude、aquila 自称
|
||||
-- GPT)—— 那正是用户说的"模型不正确"。
|
||||
--
|
||||
-- 放在这里而不是解析器里,是因为解析器还要负责**元数据合并**:同一模型在
|
||||
-- solo_agent 里有思考档位、在 solo_work_lite 里没有,合并必须跨通道进行
|
||||
-- (否则模型显示"未提供推理等级")。策略只决定"哪些 id 对外露出"。
|
||||
local admit = nil
|
||||
local mrAdmit = plat.models_remote and plat.models_remote.admit_functions
|
||||
if type(mrAdmit) == "table" then
|
||||
admit = {}
|
||||
for _, f in ipairs(mrAdmit) do admit[f] = true end
|
||||
end
|
||||
local remote = plugin.state.remoteModels and plugin.state.remoteModels[provider]
|
||||
local out, seen = {}, {}
|
||||
-- 远端在前(权威顺序)
|
||||
@ -3745,17 +3825,38 @@ local function effectiveModels(provider)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 兜底补位(只补远端没有的)
|
||||
for _, id in ipairs(plat.models or {}) do
|
||||
if not seen[id] then seen[id] = true out[#out+1] = id end
|
||||
-- 兜底补位(只补远端没有的)。
|
||||
-- ⚠️ plat.models_fallback_no_supplement=true 的平台**不补**:远端目录可用时
|
||||
-- 它就是权威,兜底表只是"拉不到时的替代",补进来会把已下架/错映射的名字
|
||||
-- 重新塞进列表(见该字段注释里的实测)。
|
||||
local haveRemote = type(remote) == "table" and type(remote.ids) == "table" and #remote.ids > 0
|
||||
if not (plat.models_fallback_no_supplement and haveRemote) then
|
||||
for _, id in ipairs(plat.models or {}) do
|
||||
if not seen[id] then seen[id] = true out[#out+1] = id end
|
||||
end
|
||||
end
|
||||
-- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。
|
||||
-- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 ——
|
||||
-- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。
|
||||
local dead = deadModelIds(provider)
|
||||
local metaAll = plugin.state.modelMeta and plugin.state.modelMeta[provider] or {}
|
||||
local kept = {}
|
||||
for _, id in ipairs(out) do
|
||||
if not dead[id] then kept[#kept+1] = id end
|
||||
local keep = (not dead[id]) and (not unavailable[id])
|
||||
if keep and admit then
|
||||
local e = metaAll[id]
|
||||
-- 有目录元数据 ⇒ 按它声明的通道判;没有元数据(兜底/手工源)⇒ 不拦,
|
||||
-- 因为"目录没说"不等于"不可调用"。
|
||||
if type(e) == "table" and type(e.chans) == "table" then
|
||||
keep = false
|
||||
for _, c in ipairs(e.chans) do
|
||||
if admit[c] then keep = true break end
|
||||
end
|
||||
elseif type(e) == "table" and type(e.chan) == "string" and e.chan ~= "" then
|
||||
keep = admit[e.chan] == true
|
||||
end
|
||||
end
|
||||
if keep then kept[#kept+1] = id end
|
||||
end
|
||||
return kept
|
||||
end
|
||||
@ -4052,10 +4153,20 @@ local function parseRemoteModels(kind, body, provider)
|
||||
local groups = rec.function_configs or rec.functionConfigs
|
||||
if type(groups) == "table" then
|
||||
local byId, chosenRank, order = {}, {}, {}
|
||||
-- chansOf[id]:该 id 在**哪些通道**里出现过(合并只留一条元数据,但
|
||||
-- "在哪些通道可发"是策略判定要用的信息 —— admit_functions 不能只看
|
||||
-- 合并后那条的 chan:同模型常在有档位的 solo_agent 与我们要发的
|
||||
-- solo_work_lite 同时出现,合并会选前者,导致误判"不可发")。
|
||||
local chansOf = {}
|
||||
for _, g in ipairs(groups) do
|
||||
if type(g) == "table" then
|
||||
local chan = rstr(g, "function", "Function")
|
||||
-- 准入:未声明 function 或不在白名单 → 整组丢弃
|
||||
--
|
||||
-- ⚠️ 「白名单」只回答"该通道在本适配器的推理端点下**可调用**";
|
||||
-- "适配器会不会发这条通道"是另一回事,由 effectiveModels 的
|
||||
-- models_remote.admit_functions 过滤(那是产品策略,不该写进解析器:
|
||||
-- 解析器的职责是把目录如实解出来,含元数据合并)。
|
||||
if chan and rankOf[chan] then
|
||||
local list = g.config_info_list or g.ConfigInfoList
|
||||
if type(list) == "table" then
|
||||
@ -4071,6 +4182,8 @@ local function parseRemoteModels(kind, body, provider)
|
||||
-- is_custom_model: 仅可见但不可调用(上游流内 4001 拒绝)
|
||||
if e.custom == true then ok = false end
|
||||
if ok then
|
||||
chansOf[e.id] = chansOf[e.id] or {}
|
||||
chansOf[e.id][chan] = true
|
||||
local inc = byId[e.id]
|
||||
if inc == nil then
|
||||
byId[e.id] = e
|
||||
@ -4106,7 +4219,14 @@ local function parseRemoteModels(kind, body, provider)
|
||||
-- 上层只能显示"未提供推理等级")。
|
||||
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]
|
||||
local entry = byId[id]
|
||||
if type(entry) == "table" and chansOf[id] then
|
||||
local list = {}
|
||||
for c in pairs(chansOf[id]) do list[#list+1] = c end
|
||||
table.sort(list)
|
||||
entry.chans = list
|
||||
end
|
||||
plugin.state.modelMeta[provider][id] = entry
|
||||
end
|
||||
end
|
||||
|
||||
@ -4161,6 +4281,9 @@ end
|
||||
local function refreshRemoteModels(provider)
|
||||
local plat = platformById(provider)
|
||||
if not plat then return nil, "未知平台" end
|
||||
-- 最近一次目录响应的**原始体**:解析失败时用来区分"网络/HTTP 故障"与
|
||||
-- "服务端 200 但账号侧没有可下发配置"(后者文案完全不同,见函数尾部)。
|
||||
local lastBody = nil
|
||||
local mr = plat.models_remote
|
||||
if not mr then return nil, "该平台无远端目录端点" end
|
||||
-- 取一个可用账号的凭据(无账号则无凭据,仅当 auth=none 时仍可拉)
|
||||
@ -4199,10 +4322,10 @@ local function refreshRemoteModels(provider)
|
||||
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”这类差异极难查。
|
||||
-- TRAE 的鉴权是 Cloud-IDE-JWT + 一组客户端头 —— 不能只发 Bearer。
|
||||
-- ⚠️ 必须用 **IDE 头**(traeIdeHeaders),不能用签到头:
|
||||
-- 签到头是 `api.trae.cn` 的 UG/积分形态(VSCode UA + X-Market-*),
|
||||
-- 发到 IDE 目录端点会被逐 function 判 2001(见 traeIdeHeaders 的注释)。
|
||||
local cred
|
||||
for _, a in ipairs(poolList(provider)) do
|
||||
if a.enabled ~= false then
|
||||
@ -4215,11 +4338,13 @@ local function refreshRemoteModels(provider)
|
||||
end
|
||||
if not cred then
|
||||
lastErr = "无可用 TRAE 凭据"
|
||||
elseif type(traeCheckinHeaders) ~= "function" then
|
||||
lastErr = "traeCheckinHeaders 未就绪(前向引用顺序问题)"
|
||||
elseif type(traeIdeHeaders) ~= "function" then
|
||||
lastErr = "traeIdeHeaders 未就绪(前向引用顺序问题)"
|
||||
else
|
||||
local uid = cred.uid or ""
|
||||
local h, herr = traeCheckinHeaders(plat, cred, uid)
|
||||
-- ★ 目录是 **IDE 端点**,必须用 IDE 头(不是签到头)。
|
||||
-- 2026-10-20 事故:这里曾复用 traeCheckinHeaders ⇒ 22 个 function 全回
|
||||
-- 2001 "Config detail ... not found" ⇒ 永远拉不到目录。详见 traeIdeHeaders。
|
||||
local h, herr = traeIdeHeaders(plat, cred, "application/json")
|
||||
if herr then
|
||||
lastErr = tostring(herr)
|
||||
else
|
||||
@ -4256,12 +4381,30 @@ local function refreshRemoteModels(provider)
|
||||
if err or not resp or resp.status ~= 200 then
|
||||
lastErr = (err or ("HTTP " .. tostring(resp and resp.status)))
|
||||
else
|
||||
lastBody = resp.body or ""
|
||||
for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do
|
||||
if not seen[id] then seen[id] = true merged[#merged+1] = id end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #merged == 0 then return nil, lastErr or "远端返回空目录" end
|
||||
if #merged == 0 then
|
||||
-- ★ 把"空目录"再细分一层:远程 200 但一条配置都没有,通常不是网络问题,
|
||||
-- 而是**账号侧**没有可下发的 function 配置(企业租户账号实测:22 个
|
||||
-- function 全回 error_code 2001 "Config detail for function: X not found")。
|
||||
-- 这两类原因的处置完全不同(网络重试 vs 找租户管理员),日志不能都说
|
||||
-- "远端返回空目录"。判据只看响应体里出现 2001 或 function_configs。
|
||||
if not lastErr then
|
||||
local seenBody = false
|
||||
if type(lastBody) == "string" and lastBody:find("function_configs", 1, true) then
|
||||
seenBody = true
|
||||
end
|
||||
if seenBody then
|
||||
return nil, "远端 200 但无模型配置(账号侧未下发 function config,"
|
||||
.. "企业/租户账号需管理员开启,实测 error_code 2001)"
|
||||
end
|
||||
end
|
||||
return nil, lastErr or "远端返回空目录"
|
||||
end
|
||||
if not plugin.state.remoteModels then plugin.state.remoteModels = {} end
|
||||
plugin.state.remoteModels[provider] = {
|
||||
ids = merged,
|
||||
@ -4502,6 +4645,9 @@ local function syncProviderSource(provider)
|
||||
if plat.endpoint then src.endpoint = plat.endpoint end
|
||||
if plat.endpoint_stream then src.endpoint_stream = plat.endpoint_stream end
|
||||
if plat.headers_extra then src.headers = plat.headers_extra end
|
||||
-- 声明式上游重试策略(见平台上的 retry_policy 注释):内核只按 status +
|
||||
-- body 子串匹配,不认识任何厂商。
|
||||
if type(plat.retry_policy) == "table" then src.retry = plat.retry_policy end
|
||||
-- ★ 把**设备身份**随源带给适配器:有些平台的头不是简单 Bearer,
|
||||
-- 需要 uid / device_id / machine_id 派生(trae 的 X-Uid / X-Device-Id /
|
||||
-- X-Machine-Id)。适配器沙箱里**读不到凭据**,只能靠 meta 传。
|
||||
@ -7184,6 +7330,13 @@ local DEAD_GONE_PATTERNS = {
|
||||
"unknown%s+model",
|
||||
"no%s+such%s+model",
|
||||
"nonexistent%s+model",
|
||||
-- ★ opencode zen 的下架文案(2026-10-20 实测):
|
||||
-- 410 `Model space-bunny-free has been deprecated. Use minimax-m3.1-flash-free instead.`
|
||||
-- 此前不识别 ⇒ 该模型一直留在源里,用户选中必失败("渠道不可用"观感)。
|
||||
-- 带 "has been deprecated" 整句是为了不误伤「参数 X 已弃用」这类**非模型**文案:
|
||||
-- "Model ... has been deprecated" 的主语必须是 Model(%s+ 吃掉名字)。
|
||||
"model%s+%S+%s+has%s+been%s+deprecated",
|
||||
"model%s+has%s+been%s+deprecated",
|
||||
}
|
||||
|
||||
-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。
|
||||
@ -8089,6 +8242,7 @@ function plugin.model_list(req)
|
||||
-- models_hidden:内部子 agent(照参考 isHidden 语义)不进用户可选列表。
|
||||
local hidden = {}
|
||||
for _, m in ipairs(plat.models_hidden or {}) do hidden[m] = true end
|
||||
for _, m in ipairs(plat.models_unavailable or {}) do hidden[m] = true end
|
||||
-- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。
|
||||
local free = {}
|
||||
for _, m in ipairs(plat.models_free or {}) do free[m] = true end
|
||||
@ -8989,14 +9143,42 @@ end
|
||||
function plugin.list_platforms(req)
|
||||
local out = {}
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
-- ★ logged_in 必须看**账号池**,不能只看旧的单账号键。
|
||||
--
|
||||
-- 真实缺陷(2026-10-20 用户报「codearts 还是不可用」):这里原本
|
||||
-- `credentialOf("channels:" .. id)` —— 那是**旧版单账号**的保险库键;
|
||||
-- 现网凭据键是 `channels:<platform>:<accountId>`(一平台多账号),
|
||||
-- 于是**所有**通过账号池接入的平台都被判成未登录(buddy 之所以显示已登录,
|
||||
-- 是因为它还留着一条旧格式凭据)。UI 据此把渠道卡片渲染成"未接入",
|
||||
-- 用户看到的就是"渠道不可用",而底层模型其实完全能用。
|
||||
local logged, first, claimed = false, nil, nil
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
if a.enabled ~= false and credentialOf(a.credentialRef) ~= nil then
|
||||
logged = true
|
||||
if first == nil then first = a end
|
||||
if claimed == nil and a.last_claim ~= nil then claimed = a.last_claim end
|
||||
end
|
||||
end
|
||||
-- 兼容旧单账号:池里没有时回落到 state.accounts + 旧凭据键
|
||||
local acct = accountOf(p.id) or {}
|
||||
local has = credentialOf("channels:" .. p.id)
|
||||
local hasLegacy = credentialOf("channels:" .. p.id) ~= nil
|
||||
if not logged and hasLegacy then logged = true end
|
||||
if first == nil and (hasLegacy or acct.account ~= nil) then
|
||||
first = { id = acct.id, nickname = acct.account, enabled = acct.enabled }
|
||||
end
|
||||
out[#out + 1] = {
|
||||
id = p.id, name = p.name, flow = p.flow,
|
||||
logged_in = has ~= nil,
|
||||
account = acct.account,
|
||||
enabled = acct.enabled,
|
||||
last_claim = acct.last_claim,
|
||||
logged_in = logged,
|
||||
-- 账号展示:池里第一个可用账号优先(id 是 12 位 hex,UI 用来区分同平台多账号)
|
||||
account_id = first and first.id or nil,
|
||||
account = (first and first.nickname) or acct.account,
|
||||
enabled = first and first.enabled ~= false or acct.enabled,
|
||||
last_claim = claimed or acct.last_claim,
|
||||
accounts = (function()
|
||||
local n = 0
|
||||
for _, a in ipairs(poolList(p.id)) do if a.enabled ~= false then n = n + 1 end end
|
||||
return n
|
||||
end)(),
|
||||
}
|
||||
end
|
||||
return { status = 200, json = { platforms = out } }
|
||||
@ -9335,8 +9517,32 @@ end
|
||||
|
||||
function plugin.status_page(req)
|
||||
local r = __llmsproxy_kernel.call("status", {})
|
||||
-- accounts 必须**合并账号池**:只回 state.accounts(旧单账号表)时,
|
||||
-- 通过账号池接入的平台在这里是空的,UI 的状态页于是显示"没有账号",
|
||||
-- 与 platforms 的 logged_in 一样会让人误判成"渠道不可用"
|
||||
-- (同一根因的两个出口,2026-10-20 一起修)。
|
||||
local accounts = {}
|
||||
for k, v in pairs(plugin.state.accounts or {}) do accounts[k] = v end
|
||||
for _, p in ipairs(allPlatforms()) do
|
||||
local list = {}
|
||||
for _, a in ipairs(poolList(p.id)) do
|
||||
list[#list + 1] = {
|
||||
id = a.id, nickname = a.nickname, enabled = a.enabled ~= false,
|
||||
expiresAt = a.expiresAt, last_claim = a.last_claim,
|
||||
has_credential = credentialOf(a.credentialRef) ~= nil,
|
||||
}
|
||||
end
|
||||
if #list > 0 then
|
||||
local base = accounts[p.id] or {}
|
||||
local merged = {}
|
||||
for k, v in pairs(base) do merged[k] = v end
|
||||
merged.accounts = list
|
||||
merged.count = #list
|
||||
accounts[p.id] = merged
|
||||
end
|
||||
end
|
||||
return { status = 200, json = {
|
||||
accounts = plugin.state.accounts,
|
||||
accounts = accounts,
|
||||
log = plugin.state.log,
|
||||
kernel_ready = r and r.ready or false,
|
||||
} }
|
||||
@ -9749,6 +9955,52 @@ traeCheckinHeaders = function(plat, cred, uid)
|
||||
return h, nil
|
||||
end
|
||||
|
||||
-- traeIdeHeaders(plat, cred, accept):**IDE/Agent 端点**的头(对齐参考 traeSOLOHeaders)。
|
||||
--
|
||||
-- ⚠️ 与 traeCheckinHeaders 是**两套不同的头**,不能互相替代:
|
||||
-- - traeCheckinHeaders 面向 `api.trae.cn/trae/api/v2/ug|pay`(签到/积分):
|
||||
-- VSCode 形态的 UA + X-Market-* + X-Lscbd-* + Vscode-Sessionid,
|
||||
-- 设备身份用**派生**的 market device id。
|
||||
-- - traeIdeHeaders 面向 `trae-api-cn.mchost.guru/api/...`(模型目录、对话):
|
||||
-- Trae/0.1.52 UA + X-App-Id + X-Ide-Version(*) + Request-Traffic-Type,
|
||||
-- 设备身份用**凭据里登录时的** device_id / machine_id。
|
||||
--
|
||||
-- 2026-10-20 真实事故:目录刷新复用了 traeCheckinHeaders,上游对 22 个 function
|
||||
-- **全部**回 `error_code 2001 Config detail for function: X not found`(HTTP 200),
|
||||
-- 于是插件永远拿不到远端目录、只剩兜底表 —— 用 IDE 头直发同一端点,返回
|
||||
-- 2.6 MB、每个 function 都带 config_info_list。症状("拉不到上游目录")与真因
|
||||
-- (头用错了)之间隔着整整一层协议,这就是必须分开写、并在注释里点明的原因。
|
||||
traeIdeHeaders = function(plat, cred, accept)
|
||||
local tok = tostring((cred and cred.access_token) or "")
|
||||
if tok == "" then return nil, "缺少 access_token" end
|
||||
local h = {
|
||||
["Content-Type"] = "application/json",
|
||||
["Accept"] = accept or "application/json",
|
||||
["User-Agent"] = (plat and plat.user_agent) or "Trae/0.1.52",
|
||||
["Authorization"] = "Cloud-IDE-JWT " .. tok,
|
||||
["X-Cloudide-Token"] = tok,
|
||||
["X-Ide-Token"] = tok,
|
||||
["X-App-Id"] = (plat and plat.app_id) or "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
|
||||
["X-App-Version"] = "default",
|
||||
["X-Ide-Version"] = (plat and plat.ide_version) or "0.1.52",
|
||||
["X-Ide-Version-Code"] = (plat and plat.ide_version_code) or "20260811",
|
||||
["X-App-Version-Code"] = (plat and plat.ide_version_code) or "20260811",
|
||||
["X-Ide-Version-Type"] = "stable",
|
||||
["X-Device-Type"] = (plat and plat.device_type) or "macos",
|
||||
["X-OS-Version"] = (plat and plat.os_version) or "macOS 15.7.4",
|
||||
["X-Device-Brand"] = (plat and plat.device_brand) or "Apple",
|
||||
["Request-Traffic-Type"] = "prod",
|
||||
}
|
||||
local uid = tostring((cred and cred.uid) or "")
|
||||
if uid ~= "" then h["X-Uid"] = uid end
|
||||
-- ★ 设备身份只认凭据里登录时落下的那两个值;不派生、不造。
|
||||
local mid = tostring((cred and cred.machine_id) or "")
|
||||
local did = tostring((cred and cred.device_id) or "")
|
||||
if mid ~= "" then h["X-Machine-Id"] = mid end
|
||||
if did ~= "" then h["X-Device-Id"] = did end
|
||||
return h, nil
|
||||
end
|
||||
|
||||
-- 表驱动签到推广到需自定义头的平台:在 PLATFORMS 定义后绑定。
|
||||
--
|
||||
-- credentialRef 是「秘密引用」,creds 是插件自解密后的 JSON(最可靠)。
|
||||
@ -9897,7 +10149,21 @@ end
|
||||
-- build_headers:内核按源注入 meta{url,method,body,api_key,...}。
|
||||
-- 凭据是完整 JSON(登录时存的就是它),从中解出 AK/SK/ST。
|
||||
function adapter.build_headers(meta)
|
||||
local h = { ["Content-Type"] = "application/json" }
|
||||
-- ★★ Connection: close 是**必须**的,不是优化。
|
||||
--
|
||||
-- CodeArts 后端按**账户**限制并发会话数(3 个),而它把"会话"绑定在
|
||||
-- HTTP 连接上:内核的 Go transport 会保留空闲连接(默认每 host 2 条)
|
||||
-- 用于 keep-alive,于是第 3~4 个请求就被判成"并发会话数已达上限(3个)"
|
||||
-- (TM.00001041),用户看到的是 codeart 连续不可用。
|
||||
--
|
||||
-- 实证(2026-10-20):
|
||||
-- * 走网关连续 5 次 → #1/#2/#3 成功,#4/#5 全部 TM.00001041;
|
||||
-- * 同一凭据、每请求**新连接 + Connection: close** 连续 5 次 → 5/5 成功;
|
||||
-- * 之后连续流式也一样:3 次成功后开始 400。
|
||||
-- 即"会话"就是连接,关掉连接会话立刻释放。
|
||||
--
|
||||
-- 代价是每次多一次 TCP/TLS 握手(~百毫秒级),换"渠道真的能用",值得。
|
||||
local h = { ["Content-Type"] = "application/json", ["Connection"] = "close" }
|
||||
local raw = tostring(meta.api_key or "")
|
||||
local cred = nil
|
||||
if raw:sub(1, 1) == "{" then
|
||||
@ -9915,6 +10181,19 @@ function adapter.build_headers(meta)
|
||||
return h
|
||||
end
|
||||
for k, v in pairs(signed) do h[k] = v end
|
||||
-- ★★ Session-Id 是**必须**的,不是可选项 —— 它决定"并发会话数"怎么算。
|
||||
--
|
||||
-- 实测(2026-10-20,对照参考实现 llm-adapter.ts:1168 `headers.set('Session-Id', …)`):
|
||||
-- 不发送会话号时,后端把**每个请求**当成一个新会话;而会话在响应结束后还要
|
||||
-- 存活一段时间才释放,于是连续 3 个请求就把账户的「并发会话数上限(3个)」占满,
|
||||
-- 之后所有请求(含流式)稳定回 TM.00001041 / 200+空流(用户看到 empty stream)。
|
||||
-- 发一个**稳定**的会话号(同一账号内恒定),后端复用同一会话 ⇒ 不再累加。
|
||||
--
|
||||
-- 会话号取 AK 的 sha256 前 32 hex:稳定、可复现、不引入需要持久化的状态。
|
||||
local ak = tostring(cred.access_key_id or cred.access_key or "")
|
||||
if ak ~= "" then
|
||||
h["Session-Id"] = string.sub(sha256_hex(ak), 1, 32)
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
@ -12986,6 +13265,105 @@ function plugin.__list_sources_probe()
|
||||
return { json = { sources = out } }
|
||||
end
|
||||
|
||||
-- plugin.__platforms_probe:只给判据用 —— 原样返回 `platforms` 路由的数据。
|
||||
--
|
||||
-- 为什么需要它:`list_platforms` 是 plugin.http 的路由处理器,**不在**
|
||||
-- plugin.exports 里(exports 是给 WebUI 直接调用的白名单),判据无法直接
|
||||
-- Call 到它。走探针是仓库既有做法(见 channels_remove_source_perm_test.go
|
||||
-- 对 `status` 的同类处理),避免为了测试把只读状态处理器塞进 exports。
|
||||
function plugin.__platforms_probe(args)
|
||||
local r = plugin.list_platforms(nil) or {}
|
||||
return { json = r.json or {} }
|
||||
end
|
||||
|
||||
-- plugin.__trae_headers_probe:只给判据用 —— 返回 trae 两套头各自的键集合。
|
||||
--
|
||||
-- 存在理由:2026-10-20 事故是「目录刷新用了签到头(traeCheckinHeaders)而不是
|
||||
-- IDE 头(traeIdeHeaders)」,上游因此对每个 function 回 2001、目录恒空。
|
||||
-- 这个错误**在代码里看不出来**(两套头都"能跑"),只能靠契约钉死:
|
||||
-- - 签到头面向 api.trae.cn 的 UG/积分:必须有 X-Market-Client-Id / X-Lscbd-*,
|
||||
-- **不得**有 X-App-Id / X-Machine-Id(那是 IDE 端的身份);
|
||||
-- - IDE 头面向 trae-api-cn.mchost.guru:必须有 X-App-Id / X-Ide-Version /
|
||||
-- X-Machine-Id,**不得**有 X-Market-Client-Id。
|
||||
-- 判据比对键集合即可,不需要真实网络。
|
||||
function plugin.__trae_headers_probe(args)
|
||||
local plat = platformById("trae")
|
||||
local cred = {
|
||||
access_token = "jwt-test", uid = "1000",
|
||||
device_id = "dev-32", machine_id = "mid-32",
|
||||
}
|
||||
local ide = traeIdeHeaders(plat, cred, "application/json")
|
||||
local chk = traeCheckinHeaders(plat, cred, cred.uid)
|
||||
local function keyset(h)
|
||||
local out = {}
|
||||
for k in pairs(h or {}) do out[#out + 1] = k end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
return { json = { ide = keyset(ide), checkin = keyset(chk) } }
|
||||
end
|
||||
|
||||
-- plugin.__trae_catalog_probe:只给诊断用 —— 原样回传 TRAE 远端模型目录的
|
||||
-- HTTP 状态与前 1200 字节响应体。
|
||||
--
|
||||
-- 存在理由:`refresh_models` 只把「解析出几条」汇总成「远端返回空目录」,
|
||||
-- 看不到**上游到底回了什么**(空数组?错误信封?形状变了?)。模型目录决定
|
||||
-- 用户在渠道页能选哪些模型,它是"渠道不可用"类报障的第一现场,不能只有一句
|
||||
-- 二手结论。探测逻辑与 refreshRemoteModels 的 trae 分支**同源**(同一个
|
||||
-- traeCheckinHeaders + 同一份请求体),避免"探针通、真路径不通"。
|
||||
function plugin.__trae_catalog_probe(args)
|
||||
args = args or {}
|
||||
local plat = platformById("trae")
|
||||
if not plat or not plat.models_remote then
|
||||
return { json = { error = "trae 平台未声明 models_remote" } }
|
||||
end
|
||||
local ep = (plat.models_remote.endpoints or {})[1] or {}
|
||||
local cred
|
||||
for _, a in ipairs(poolList("trae")) 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 return { json = { error = "无可用 TRAE 凭据" } } end
|
||||
if type(traeCheckinHeaders) ~= "function" then
|
||||
return { json = { error = "traeCheckinHeaders 未就绪" } }
|
||||
end
|
||||
local h, herr = traeCheckinHeaders(plat, cred, cred.uid or "")
|
||||
if herr then return { json = { error = "构造头部失败: " .. tostring(herr) } } end
|
||||
-- 诊断用覆盖:Accept 在参考里对目录请求是 application/json,而 checkin 的
|
||||
-- 头部构造给的是 text/event-stream —— 用它可以判定"是不是 Accept 导致空目录"。
|
||||
if type(args.accept) == "string" and args.accept ~= "" then
|
||||
h["Accept"] = args.accept
|
||||
end
|
||||
-- 诊断用:额外头部(企业/租户类账号的 IDE 端点通常要求租户身份,
|
||||
-- 而参考的 traeSOLOHeaders 不发。用它验证"缺租户头导致全 2001")。
|
||||
if type(args.headers) == "table" then
|
||||
for k, v in pairs(args.headers) do
|
||||
if type(k) == "string" and type(v) == "string" then h[k] = v end
|
||||
end
|
||||
end
|
||||
if type(args.ide_version) == "string" and args.ide_version ~= "" then
|
||||
h["X-Ide-Version"] = args.ide_version
|
||||
h["X-App-Version-Code"] = args.ide_version
|
||||
h["X-Ide-Version-Code"] = args.ide_version
|
||||
end
|
||||
local resp, err = __llmsproxy_http({
|
||||
url = (type(args.url) == "string" and args.url ~= "" and args.url) or ep.url,
|
||||
method = ep.method or "POST",
|
||||
headers = h, body = (type(args.body) == "string" and args.body ~= "" and args.body) or ep.body or "{}",
|
||||
})
|
||||
if err then return { json = { error = tostring(err) } } end
|
||||
local body = (resp and resp.body) or ""
|
||||
return { json = {
|
||||
status = resp and resp.status or 0,
|
||||
bytes = #body,
|
||||
head = body:sub(1, math.min(tonumber(args.clip) or 1200, #body)),
|
||||
parsed = #parseRemoteModels("trae_batch", body, "trae"),
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__trae_entry_probe:只给测试/诊断用 —— 报告某模型最终选中条目的关键字段。
|
||||
-- “合并规则选错了条目”(空档位覆盖有档位,issue IKI7WT/IKILR7)这类问题
|
||||
-- 没有它只能靠猜。
|
||||
|
||||
@ -1156,7 +1156,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, status, err := p.do(ctx, chatURL, body, hdrs)
|
||||
raw, status, err := p.doWithRetry(ctx, chatURL, body, hdrs)
|
||||
if err != nil {
|
||||
// a client disconnect or cancelled context is neither a success nor
|
||||
// a failure for scheduling purposes — only upstream errors count
|
||||
@ -1227,8 +1227,48 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
}
|
||||
rc := make(chan respOrErr, 1)
|
||||
go func() {
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
rc <- respOrErr{resp, err}
|
||||
// Retry only the HANDSHAKE: a policy-matched rejection (e.g. a
|
||||
// per-account concurrency cap answered with a business 4xx) can clear
|
||||
// within seconds, and nothing has been sent to the client yet — the
|
||||
// kernel holds the first chunk back. Once a 200 arrives and streaming
|
||||
// starts, no retry happens (that would duplicate output).
|
||||
rp := p.cfg.Retry
|
||||
attempts := 1
|
||||
if rp != nil && rp.MaxAttempts > 1 {
|
||||
attempts = rp.MaxAttempts
|
||||
}
|
||||
for i := 0; i < attempts; i++ {
|
||||
resp, err := p.doRawStream(ctx, streamURL, body, hdrs)
|
||||
if err != nil {
|
||||
rc <- respOrErr{resp, err}
|
||||
return
|
||||
}
|
||||
if resp.StatusCode == 200 {
|
||||
rc <- respOrErr{resp, nil}
|
||||
return
|
||||
}
|
||||
// A rejection can only be classified after the body is read: the
|
||||
// policy matches on body substrings, and a source may use the same
|
||||
// status (400) for unrelated errors that must NOT be retried.
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !rp.ShouldRetry(resp.StatusCode, string(raw)) {
|
||||
// Not the retryable body: hand the rejection back verbatim so
|
||||
// the caller reports the real upstream error.
|
||||
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||
rc <- respOrErr{resp2, nil}
|
||||
return
|
||||
}
|
||||
if i == attempts-1 {
|
||||
resp2 := &http.Response{StatusCode: resp.StatusCode, Body: io.NopCloser(strings.NewReader(string(raw)))}
|
||||
rc <- respOrErr{resp2, nil}
|
||||
return
|
||||
}
|
||||
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
|
||||
rc <- respOrErr{nil, serr}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
inner := make(chan types.UnifiedChunk, 64)
|
||||
@ -1560,6 +1600,50 @@ func (p *Provider) do(ctx context.Context, url, body string, hdr http.Header) (s
|
||||
return string(raw), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// doWithRetry runs one non-stream request, retrying per the source's
|
||||
// declarative RetryPolicy. The policy is pure data (status list + body
|
||||
// substrings), so the kernel stays vendor-agnostic: a provider that answers a
|
||||
// per-account concurrency cap with a business 4xx can clear by itself while the
|
||||
// client just waits a few seconds instead of seeing an error.
|
||||
func (p *Provider) doWithRetry(ctx context.Context, url, body string, hdr http.Header) (string, int, error) {
|
||||
rp := p.cfg.Retry
|
||||
attempts := 1
|
||||
if rp != nil && rp.MaxAttempts > 1 {
|
||||
attempts = rp.MaxAttempts
|
||||
}
|
||||
var raw string
|
||||
var status int
|
||||
for i := 0; i < attempts; i++ {
|
||||
r, st, err := p.do(ctx, url, body, hdr)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
raw, status = r, st
|
||||
if status == 200 || !rp.ShouldRetry(status, raw) || i == attempts-1 {
|
||||
break
|
||||
}
|
||||
if serr := sleepCtx(ctx, rp.DelayDuration()); serr != nil {
|
||||
return "", 0, serr
|
||||
}
|
||||
}
|
||||
return raw, status, nil
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d unless the request context ends first.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) doRaw(ctx context.Context, url, body string, hdr http.Header) (*http.Response, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
|
||||
131
internal/provider/retry_policy_test.go
Normal file
131
internal/provider/retry_policy_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// 上游重试是**内核的通用机制**,判据纯数据(status + body 子串),
|
||||
// 目的是让"账号级限流、等一会儿就好"这类拒绝不冒到客户端。
|
||||
//
|
||||
// 真实事故(2026-10-20):CodeArts 账户并发会话上限 3 个,连续第 3~4 个请求
|
||||
// 回 400 TM.00001041,最后一个请求结束后约 44~60 秒才释放;流式更糟 ——
|
||||
// 上游用 HTTP 200 + error 帧表达拒绝,内核只能报 `empty stream`。
|
||||
// 参考实现的处理是每 10s 重试一次 chat 直到放行,本测试钉住同一语义。
|
||||
func TestChatRetriesDeclaredUpstreamRejection(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
if n <= 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 4,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
resp, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("命中重试策略后应成功,实得错误: %v", err)
|
||||
}
|
||||
if resp.Content != "hi" {
|
||||
t.Fatalf("content = %q", resp.Content)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("应恰好尝试 3 次(2 次失败 + 1 次成功),实得 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一状态码但 body 不匹配 ⇒ **不许重试**(400 也常用于真正的请求错误,
|
||||
// 无差别重试只会把错误延迟 3 分钟才暴露给用户)。
|
||||
func TestChatDoesNotRetryUnmatchedBody(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001005","error_msg":"无正常可用套餐"}`)
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 5,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
_, err := p.Chat(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("不可重试的 body 必须失败")
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("body 不匹配时只能尝试 1 次,实得 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 流式:只重试**握手**。命中策略 → 重试直到 200 才开始派发;一旦开始流式
|
||||
// 就不再重试(重复输出比报错更糟)。
|
||||
func TestChatStreamRetriesHandshakeOnly(t *testing.T) {
|
||||
var hits int32
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
if n == 1 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprint(w, `{"error_code":"TM.00001041","error_msg":"并发会话数已达上限(3个)"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n")
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer up.Close()
|
||||
|
||||
s := src("codearts-like", up.URL, "openai", "m1")
|
||||
s.Retry = &config.RetryPolicy{
|
||||
OnStatus: []int{400},
|
||||
OnBodyContains: []string{"TM.00001041"},
|
||||
Delay: "1ms",
|
||||
MaxAttempts: 4,
|
||||
}
|
||||
p := newTestProvider(t, s)
|
||||
ch, err := p.ChatStream(context.Background(), &types.ChatRequest{
|
||||
Model: "m1",
|
||||
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("握手重试后应成功: %v", err)
|
||||
}
|
||||
var content string
|
||||
for ck := range ch {
|
||||
content += ck.Content
|
||||
}
|
||||
if content != "hi" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||
t.Fatalf("应握手重试 1 次(共 2 次请求),实得 %d", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user