Compare commits

..

6 Commits

Author SHA1 Message Date
0ae7d2c8be test(retry): 钉住声明式重试判据与流式握手重试语义
- config:ShouldRetry/DelayDuration 表驱动(nil/次数=1/状态不匹配/body 不匹配/
  空子串/多信号/非法间隔)
- provider:命中策略就重试直到 200、body 不匹配**不许**重试(400 也常用于真错误)、
  流式只重试握手(首帧之后不重试,避免重复输出)
2026-10-20 10:45:56 +08:00
02c01582f1 chore(gui): 版本号 2.0.46(与内核同版本) 2026-10-20 10:38:07 +08:00
6454b094bc fix(codearts): 上游限流改为可声明重试(内核通用能力)+ 会话/连接两处真因
用户连续报「codeart 还是不可用 / empty stream」。把上游原始响应打出来逐条对齐,
三个症状同源,且都不是"模型坏了":

| 场景 | 上游实际返回 |
|---|---|
| 流式、短时间连续请求 | HTTP **200** + 只带 error 的帧 ⇒ 内核判 `empty stream` |
| 非流式、同一条件 | HTTP 400 `TM.00001041: 并发会话数已达上限(3个)` |
| 突发请求 | HTTP 429 `TPM limit … 500000 tokens per minute` |

实测:最后一个请求结束后约 **44~60 秒**释放;同一账号连续请求 3~4 次即占满。
参考实现(示例插件 llm-adapter.ts)的做法是**每 10s 轮询后重试 chat**(最多 30
分钟),所以用户那边"看起来没事"。

## 我们的改法(内核只做通用机制,厂商知识留在插件)
1. **内核新增声明式重试策略** `source.retry`(纯数据:状态码列表 + 响应体子串 +
   间隔 + 次数)。内核不认识任何厂商;命中即在同一网关请求内每 10s 重试一次。
   流式只在**握手阶段**重试(首帧尚未下发,不会重复输出),非流式整体重试。
2. **插件为 codearts 声明**:400/429 + 命中 `TM.00001041` / `TPM limit` /
   `并发会话数已达上限` → 10s × 最多 18 次(≈3 分钟)。
3. **适配器补两个会话细节**:`Connection: close`(keep-alive 空闲连接会被上游
   计入并发会话)与稳定 `Session-Id`(同一账号恒定,避免每次请求新建会话)。
4. 错误文案补可行动提示(并发会话上限 / TPM 限流,约 1 分钟释放)。

## 验证(生产)
- 连续 6 次**流式**:6/6 成功(撞上限时自动等 10s 再试,客户端只感觉慢):
  `+0s / +12s / +34s / +86s / +100s / +144s`
- 连续 5 次**非流式**:5/5 成功(`+0 / +11 / +20 / +28 / +38s`)
- 修复前:第 4 个请求起必然 TM.00001041 / empty stream。

> 注:`TM.00001005 无正常可用套餐` 属于账号权益问题(不重试、如实报出),
> 与限流要分开处理。
2026-10-20 10:38:00 +08:00
771b95a133 fix(channels): admit_functions 落到 effectiveModels(解析器不承担产品策略)
上一提交把「本适配器会发的通道」过滤写进了 trae 的目录解析器,导致两个解析器判据
回归:同模型在 solo_agent(有思考档位)与 solo_work_lite(无档位)同时出现时,
整组 solo_agent 被丢弃 ⇒ 档位信息丢光,模型显示"未提供推理等级"。

正确分层:
- **解析器**只负责把目录如实解出来(含跨通道元数据合并、白名单准入);
- **产品策略**(只暴露适配器能发的通道)放到 effectiveModels,作用在对外
  暴露的 id 列表上。

实现要点:合并会选"更优"的那条(往往不在我们要发的通道里),所以判据不能看
合并后条目的 chan —— 解析时额外记 `chans`(该 id 在哪些通道出现过),策略按
chans 求交集。夹具同步调整:硬性过滤的条目放进 solo_work_lite(生产里适配器
恒发这条通道),inline_chat 仍用于白名单判据。
2026-10-20 10:12:51 +08:00
441de434f5 chore(gui): 版本号 2.0.45(与内核同版本) 2026-10-20 09:54:06 +08:00
9feef8f4cb fix(channels): 渠道卡片 logged_in 改成看账号池(此前所有池接入的平台都显示未登录)
## 真因:logged_in 判的是旧单账号键
用户报「codearts 还是不可用」。查下来渠道**本身是通的**(9 个模型逐个实测都返回
内容),不可用的是**UI 的登录态判断**:

`list_platforms` 写的是 `credentialOf("channels:" .. platform)` —— 那是**旧版
单账号**的保险库键。现网凭据键是 `channels:<platform>:<accountId>`(一平台多
账号),于是**所有**通过账号池接入的平台都被判成"未登录":

    修复前(生产):trae=False minimax=False codearts=False qoder=False zcode=False
                    (只有 buddy=True —— 它还留着一条旧格式凭据)
    修复后:          trae=True  minimax=True  codearts=True qoder=True  zcode=True

UI 据此把渠道卡片渲染成"未接入",用户看到的就是"渠道不可用"。底层模型完全能用 ——
这是最容易被误判成"上游挂了"的一类假信号。

## 修法
- `list_platforms`:logged_in 改为遍历账号池(`poolList` + 凭据存在 + enabled≠false),
  旧单账号仅作兜底;顺带回 `account_id` / `accounts` 计数,让 UI 能区分同平台多账号;
- `status_page`:`accounts` 同样合并账号池(此前只回旧表,状态页里池接入的平台
  是空的 —— 同一根因的第二个出口);
- 新增 `__platforms_probe`(`list_platforms` 是 HTTP 路由处理器、不在 exports 里,
  判据走探针,与仓库既有做法一致);
- 回归判据 `TestPlatformsLoggedInFromAccountPool`:池里有凭据账号 ⇒ logged_in=true,
  且 account_id / account / accounts 回池数据。

## 顺带核实(不是代码缺陷)
- codearts 目录里 4 个模型(`deepseek-v4-flash-0731` / `glm-5.3-flash` /
  `deepseek-v4-pro-0813` / `CodeArts-Pangu`)实测上游 404
  `InferHub.002002009.404: The model is not registered` ⇒ 之前被手工停用是对的,
  已按死模型流程收口(不再回到可选列表);
- `GLM-5.3` 同样 404,已标失效并自动从源里剔除(日志:
  「模型已失效,已标记 codearts/GLM-5.3」);
- `deepseek-v4-flash` 早前的 429 是 TPM 限流(我自己连续压测造成),现已恢复 OK。
- 最终 codearts:**9 个模型,9/9 实测返回内容**。
2026-10-20 09:54:06 +08:00
9 changed files with 581 additions and 37 deletions

View File

@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
"version": "2.0.44",
"version": "2.0.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
"version": "2.0.44",
"version": "2.0.46",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
"version": "2.0.44",
"version": "2.0.46",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",

View File

@ -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`.

View 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)
}
}
}

View 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"])
}
}

View File

@ -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}`+

View File

@ -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", "__trae_catalog_probe", "__trae_headers_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" }
@ -1046,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",
@ -3780,6 +3798,23 @@ local function effectiveModels(provider)
-- 进源只会让客户端选中后报错(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 = {}, {}
-- 远端在前(权威顺序)
@ -3804,9 +3839,24 @@ local function effectiveModels(provider)
-- ⚠️ 只剔这里记录过的(真正 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] and not unavailable[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
@ -4103,22 +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 或不在白名单 → 整组丢弃
-- ★ 追加一道「本适配器可发通道」闸门(声明式,见 models_remote.admit_functions):
-- 白名单只说明"这个通道在本适配器的推理端点下可调用",不代表
-- **适配器会发它** —— trae 适配器恒发 solo_work_lite,别的通道的
-- 模型即使可调用,经我们这条路发出去也会空回/回落默认模型。
local admitted = nil
do
local pl = platformById(provider)
local mr2 = pl and pl.models_remote
local af = mr2 and mr2.admit_functions
if type(af) == "table" then admitted = {} for _, f in ipairs(af) do admitted[f] = true end end
end
if admitted and chan and not admitted[chan] then chan = nil end
--
-- ⚠️ 「白名单」只回答"该通道在本适配器的推理端点下**可调用**";
-- "适配器会不会发这条通道"是另一回事,由 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
@ -4134,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
@ -4169,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
@ -4588,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 传。
@ -9083,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 } }
@ -9429,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,
} }
@ -10037,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
@ -10055,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
@ -13126,6 +13265,17 @@ 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)而不是

View File

@ -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 {

View 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)
}
}