dsh-router-core 0.3.14 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,7 +30,7 @@ export interface SupplierEnv {
30
30
  * 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
31
31
  *
32
32
  * 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
33
- * 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
33
+ * 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
34
34
  * 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
35
35
  * (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
36
36
  *
@@ -43,7 +43,7 @@ export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
43
43
  /**
44
44
  * 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
45
45
  *
46
- * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、是否禁用、要不要换号
46
+ * 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、要不要换号
47
47
  * 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
48
48
  *
49
49
  * 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
@@ -56,8 +56,18 @@ export type AccountState = 'ok' | 'rate_limit' | 'quota' | 'session_dead' | 'una
56
56
  * 否则组合里每有一个别人家的模型,就会给无关账号攒一次错误,攒够阈值
57
57
  * 把它冷却掉(历史上正是这个坑)。
58
58
  */
59
- | 'no_such_model';
60
- /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却/禁用由核心叠加)。 */
59
+ | 'no_such_model'
60
+ /**
61
+ * 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
62
+ *
63
+ * **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
64
+ * 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
65
+ * 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
66
+ * 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
67
+ * 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
68
+ */
69
+ | 'bad_request';
70
+ /** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
61
71
  export interface SupplierAccountNow {
62
72
  uid: string;
63
73
  nickname?: string;
@@ -92,7 +102,7 @@ export type ChatOnceResult =
92
102
  status: number;
93
103
  body: string;
94
104
  }
95
- /** 失败:核心据此做冷却/禁用/换号。 */
105
+ /** 失败:核心据此做冷却/换号。 */
96
106
  | {
97
107
  ok: false;
98
108
  state: AccountState;
@@ -114,7 +124,7 @@ export interface SupplierModule {
114
124
  * 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
115
125
  */
116
126
  readonly apiKeyHint?: string;
117
- /** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
127
+ /** 报账号「现在状态」。冷却/错误累计由核心叠加,插件不算这些。 */
118
128
  status(): SupplierStatusNow;
119
129
  /** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
120
130
  listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -100,7 +100,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
100
100
  <div className="dshr-supplierGrid">
101
101
  {group.map(supplier => {
102
102
  const supplierAccounts = accounts.filter(a => a.supplier === supplier.id)
103
- const healthy = supplierAccounts.filter(a => !a.cooling && !a.disabled).length
103
+ const healthy = supplierAccounts.filter(a => !a.cooling).length
104
104
  return (
105
105
  <section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
106
106
  <div className="dshr-supplierRow">
@@ -62,7 +62,6 @@ function untilLabel(account: RouterAccount): string {
62
62
  }
63
63
 
64
64
  function statusBadge(account: RouterAccount): { text: string; cls: string } {
65
- if (account.disabled) return { text: '已禁用', cls: 'dshr-badge-danger' }
66
65
  if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
67
66
  return { text: '健康', cls: 'dshr-badge-ok' }
68
67
  }
@@ -666,7 +665,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
666
665
  <div className="dshr-linkList">
667
666
  {orderedAccounts.map((account, index) => {
668
667
  const badge = statusBadge(account)
669
- const coolingText = account.cooling || account.disabled
668
+ const coolingText = account.cooling
670
669
  ? `${account.reason ?? ''}${untilLabel(account) !== '' ? ` · ${untilLabel(account)}` : ''}`
671
670
  : ''
672
671
  return (
@@ -620,8 +620,110 @@ test('wire:无 attachments 时图片回退占位文本,绝不静默丢图',
620
620
  messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
621
621
  }, undefined)
622
622
  const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
623
- const parts = (user?.content ?? []) as Array<{ type: string; text?: string }>
624
- assert.equal(parts.length, 1)
625
- assert.equal(parts[0]?.type, 'text')
626
- assert.ok((parts[0]?.text ?? '').includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
623
+ // 读不到字节时 parts 只剩一个 text 占位 → 收成紧凑字符串(无图可发,别硬撑数组)
624
+ const text = typeof user?.content === 'string' ? user.content : JSON.stringify(user?.content)
625
+ assert.ok(text.includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
626
+ assert.ok(!text.includes('image_url'), '读不到字节时不能伪造图片 part')
627
+ })
628
+
629
+ test('wire:一步两个 read_image(每条一个 user 消息,同现场)→ tool 消息紧跟 assistant,图片合并到末尾', async () => {
630
+ const ref = {
631
+ attachmentId: 'sha256:abcdef1234567890',
632
+ mediaType: 'image/png',
633
+ bytes: 11,
634
+ width: 4,
635
+ height: 2,
636
+ }
637
+ // **关键**:harness 把每个 tool 结果放**各自的** user-role 消息里(现场就是
638
+ // 两条独立消息)。这是第一版修复漏掉的形态——按消息各发一条 user(图) 会得到
639
+ // `tool,user,tool,user`,上游照样 400/11148。必须跨消息攒图、发完整串再补一条。
640
+ const body = await captureBodyWith({
641
+ messages: [
642
+ { role: 'user', content: [{ type: 'text', text: '读这两张图' }] },
643
+ {
644
+ role: 'assistant',
645
+ content: [
646
+ { type: 'tool-call', id: 'c1', name: 'read_image', arguments: '{}' },
647
+ { type: 'tool-call', id: 'c2', name: 'read_image', arguments: '{}' },
648
+ ],
649
+ },
650
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'tree_assembly.png' }, { type: 'image', attachment: ref }] }] },
651
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c2', content: [{ type: 'text', text: 'tree_part.png' }, { type: 'image', attachment: ref }] }] },
652
+ ],
653
+ }, fakeAttachments())
654
+ const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }>; content: unknown }>
655
+ assertToolPairingIntact(messages)
656
+
657
+ // 两条 tool 消息必须**连续**紧跟 assistant,各自的文本标签留在自己的 tool 里
658
+ const ai = messages.findIndex((m) => m.role === 'assistant')
659
+ assert.deepEqual(messages.slice(ai + 1, ai + 3).map((m) => m.role), ['tool', 'tool'], '两条 tool 必须连续,中间不得插 user')
660
+ assert.equal(messages[ai + 1]?.content, 'tree_assembly.png', '第一条 tool 带自己的标签')
661
+ assert.equal(messages[ai + 2]?.content, 'tree_part.png', '第二条 tool 带自己的标签')
662
+
663
+ // 图片合并到全部 tool 之后的那**一条** user 消息里(两张都在,不静默丢图)
664
+ const userWithImage = messages.find((m) => m.role === 'user' && Array.isArray(m.content))
665
+ assert.ok(userWithImage !== undefined, '带图内容必须序列化成 user 消息的 content 数组')
666
+ const parts = userWithImage.content as Array<{ type: string; text?: string }>
667
+ assert.equal(parts.filter((p) => p.type === 'image_url').length, 2, '两张图都要发出去')
668
+ const lastTool = messages.map((m) => m.role).lastIndexOf('tool')
669
+ const imageAt = messages.indexOf(userWithImage)
670
+ assert.ok(imageAt > lastTool, `带图 user 消息必须排在全部 tool 消息之后(实际 tool 末位 ${lastTool},图在 ${imageAt})`)
671
+ // 整段 wire 里 user(带图) 只出现一次:插两次就会把 tool 串切断
672
+ const imageUsers = messages.filter((m) => m.role === 'user' && Array.isArray(m.content))
673
+ assert.equal(imageUsers.length, 1, '带图 user 消息只能有一条(多条 = 切断了 tool 配对)')
674
+ })
675
+
676
+ /**
677
+ * 为什么要有这一组:`read_image` 的结果是**带图的 tool-result**。适配器曾把
678
+ * 图片和文本一起提成一条 `user` 消息、排在 `tool` 消息**之前**,于是 wire 里
679
+ * 出现 `assistant(tool_calls) → user(image) → tool`。上游判定 tool_call 与
680
+ * tool_result 失配,回 400 网关码 11148「tool calls and tool results do not
681
+ * match」(实测直连复现,把 tool 提前即 200)。
682
+ *
683
+ * 后果被池策略放大:11148 当时归 `unknown` → 瞬时冷却 30s;组合 money 两条腿
684
+ * 都用同一份历史,一条带图请求就把它俩同时冷掉,之后**连纯文本请求**也全灭
685
+ * 503(用户看到的现象)。所以这条不变式必须在 wire 层锁死:
686
+ * **assistant 的每个 tool_call 后面必须紧跟配套 tool 消息,中间不得插任何
687
+ * 其它角色**。
688
+ */
689
+
690
+ /** 断言 wire 里 tool_call / tool_result 严格配对且不被插队。 */
691
+ function assertToolPairingIntact(messages: Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>): void {
692
+ for (let i = 0; i < messages.length; i++) {
693
+ const m = messages[i]
694
+ if (m?.role !== 'assistant' || (m.tool_calls ?? []).length === 0) continue
695
+ const wanted = new Set((m.tool_calls ?? []).map((c) => c.id))
696
+ // 紧随其后的必须**连续**是配套 tool 消息,中途混入任何别的角色都算失配
697
+ // (现场踩的就是 user(图) 插在两条 tool 之间,wire 成 tool,user,tool,user)
698
+ let j = i + 1
699
+ const satisfied = new Set<string>()
700
+ while (j < messages.length && messages[j]?.role === 'tool') {
701
+ const id = messages[j]?.tool_call_id ?? ''
702
+ assert.ok(wanted.has(id), `第 ${j} 条 tool 消息的 tool_call_id ${JSON.stringify(id)} 不在上一条 assistant 的 tool_calls 里`)
703
+ satisfied.add(id)
704
+ j += 1
705
+ }
706
+ assert.equal(
707
+ satisfied.size,
708
+ wanted.size,
709
+ `assistant 的 tool_calls 未被紧邻的 tool 消息配齐`
710
+ + `(中间插了别的角色:${messages.slice(i + 1, j).map((x) => x.role).join(',') || '—'})`,
711
+ )
712
+ for (const id of wanted) {
713
+ assert.ok(satisfied.has(id), `tool_call ${id} 没有配套 tool 消息(上游会回 11148)`)
714
+ }
715
+ }
716
+ }
717
+
718
+ test('wire:纯文本 tool-result 不因图片逻辑改变顺序(tool 紧跟 assistant)', async () => {
719
+ const body = await captureBodyWith({
720
+ messages: [
721
+ { role: 'user', content: [{ type: 'text', text: '跑一下' }] },
722
+ { role: 'assistant', content: [{ type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
723
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
724
+ ],
725
+ }, fakeAttachments())
726
+ const messages = body.messages as Array<{ role: string; tool_call_id?: string; tool_calls?: Array<{ id: string }> }>
727
+ assertToolPairingIntact(messages)
728
+ assert.deepEqual(messages.map((m) => m.role), ['user', 'assistant', 'tool'], '纯文本 tool 结果仍紧跟 assistant')
627
729
  })
@@ -78,18 +78,40 @@ export interface RouterAdapterSource {
78
78
  * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
79
79
  * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
80
80
  */
81
+ /** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
82
+ const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
83
+
81
84
  async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
82
85
  const out: Array<Record<string, unknown>> = []
86
+ /**
87
+ * 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
88
+ * user 消息补在后面。
89
+ *
90
+ * 为什么必须跨消息攒、而不是各消息各发一条:harness 把**每个 tool 结果放
91
+ * 自己的 user-role 消息**里(读两张图 = 两条消息)。若每条消息各发一个
92
+ * `user(图)`,wire 就成 `assistant → tool → user(图) → tool → user(图)`——
93
+ * 中间那条 user 又把 tool_call/tool_result 配对接断了,上游照样 400/11148
94
+ * (实测:tool,user,tool,user = 400;tool,tool,user(两图) = 200)。
95
+ * 官方 dsh-llm-deepseek 的 serializeMessagesWithImages 同样这么做。
96
+ */
97
+ let pendingToolImages: Array<Record<string, unknown>> = []
98
+ const flushToolImages = (): void => {
99
+ if (pendingToolImages.length === 0) return
100
+ out.push({ role: 'user', content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages] })
101
+ pendingToolImages = []
102
+ }
83
103
  if (typeof system === 'string' && system !== '') {
84
104
  out.push({ role: 'system', content: system })
85
105
  }
86
106
  for (const message of options.messages) {
87
107
  if (message.role === 'system') {
88
108
  // 手写调用也可能把 system 塞进 messages(one-shot 场景),照旧支持
109
+ flushToolImages()
89
110
  out.push({ role: 'system', content: flattenText(message.content) })
90
111
  continue
91
112
  }
92
113
  if (message.role === 'assistant') {
114
+ flushToolImages()
93
115
  const text = flattenText(message.content)
94
116
  const toolCalls = message.content
95
117
  .filter((b) => b.type === 'tool-call')
@@ -108,24 +130,50 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
108
130
  }
109
131
  // user / tool-result
110
132
  const toolResults = message.content.filter((b) => b.type === 'tool-result')
111
- // 有图 → content 数组;纯文本 → 保持紧凑字符串 wire 形式不回归。
112
- if (hasImageBlock(message.content)) {
113
- const parts = await contentParts(message.content, attachments, options.signal)
114
- if (parts.length > 0) out.push({ role: 'user', content: parts })
115
- else if (toolResults.length === 0) out.push({ role: 'user', content: '' })
116
- } else {
117
- const text = flattenText(message.content)
118
- if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
133
+ // 这条消息自己的(非 tool-result 的)user 部分:文本 + 图片。
134
+ const ownParts = await contentParts(
135
+ message.content.filter((b) => b.type !== 'tool-result'),
136
+ attachments,
137
+ options.signal,
138
+ )
139
+ // 有实质 user 内容(或压根没有 tool 结果)就先发这条 user 消息;
140
+ // 只有 tool 结果的「纯工具」消息不发空 user,图片留到 pending 里合并。
141
+ if (ownParts.length > 0 || toolResults.length === 0) {
142
+ flushToolImages()
143
+ out.push({ role: 'user', content: compactUserContent(ownParts) })
119
144
  }
120
145
  for (const result of toolResults) {
146
+ // **tool 消息必须紧跟 assistant(tool_calls)**:中间插进任何 user 消息,
147
+ // 上游都会判定 tool_call 与 tool_result 失配 —— codebuddy 直接 400 网关码
148
+ // 11148「tool calls and tool results do not match」。
149
+ // 图片不能放进 tool 消息(实测模型会把两张不同的图认成同一张),
150
+ // 也不能插在两条 tool 之间(同样 400),所以先攒起来、发完整串再补一条 user。
151
+ const parts = await contentParts(result.content, attachments, options.signal)
152
+ const imageParts = parts.filter((p) => p.type !== 'text')
153
+ const text = parts.filter((p) => p.type === 'text').map((p) => (p as { text?: string }).text ?? '').join('')
121
154
  // 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
122
155
  // 以为工具真的打印了那句话(9router 也是补 content: "")。
123
- out.push({ role: 'tool', tool_call_id: result.toolCallId, content: flattenText(result.content) })
156
+ out.push({ role: 'tool', tool_call_id: result.toolCallId, content: text })
157
+ pendingToolImages.push(...imageParts)
124
158
  }
125
159
  }
160
+ flushToolImages()
126
161
  return out
127
162
  }
128
163
 
164
+ /**
165
+ * 纯文本 parts 收成紧凑字符串 wire 形式(无图时不回归成数组)。
166
+ * 与官方适配器的 userContent 同义:只要有非 text part 就保持数组,否则 join。
167
+ */
168
+ function compactUserContent(parts: Array<Record<string, unknown>>): string | Array<Record<string, unknown>> {
169
+ const texts: string[] = []
170
+ for (const p of parts) {
171
+ if (p.type !== 'text') return parts
172
+ texts.push((p as { text?: string }).text ?? '')
173
+ }
174
+ return texts.join('')
175
+ }
176
+
129
177
  /**
130
178
  * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
131
179
  * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
@@ -238,15 +286,6 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
238
286
  return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
239
287
  }
240
288
 
241
- /** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
242
- function hasImageBlock(blocks: readonly { type: string }[]): boolean {
243
- for (const b of blocks) {
244
- if (b.type === 'image') return true
245
- if (b.type === 'tool-result' && hasImageBlock((b as unknown as { content: ReadonlyArray<{ type: string }> }).content)) return true
246
- }
247
- return false
248
- }
249
-
250
289
  interface OpenBlock {
251
290
  index: number
252
291
  kind: 'text' | 'reasoning' | 'tool-call'
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 账号池(核心策略)测试 —— 选号/冷却/禁用/遍历回退。
2
+ * 账号池(核心策略)测试 —— 选号/冷却/遍历回退。
3
3
  *
4
4
  * 关键语义(v2):
5
5
  * - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
6
6
  * (A, 该连接),该连接调模型 B 不受影响。
7
- * - 禁用(session_dead) = 连接级(uid):该号所有模型都不可用。
7
+ * - session_dead = 连接级(uid)可恢复冷却:该号所有模型都不可用,到期自愈。
8
+ * **没有永久禁用**——403 分不清凭证死活与风控误伤,误伤号不该被钉死。
8
9
  * - no_such_model 不惩罚账号(模型不属于本供应商不是号的错)。
9
10
  * 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
10
11
  */
@@ -327,16 +328,18 @@ test('【颗粒度】同一连接对不同模型的退避独立累加/清零', (
327
328
  assert.equal(p.decorate(list, 'm1')[0]?.err_count, 0)
328
329
  })
329
330
 
330
- // ============ 连接级禁用(跨模型) ============
331
+ // ============ 连接级冷却(跨模型, session_dead) ============
331
332
 
332
- test('【颗粒度】session_dead 是连接级禁用:跨该号所有模型', () => {
333
+ test('【颗粒度】session_dead 是连接级冷却:跨该号所有模型,到期自愈', () => {
333
334
  const p = pool()
334
335
  const list = accs(['a'])
335
336
  p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
336
337
  // 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
337
338
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
338
339
  assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
339
- assert.equal(p.decorate(list)[0]?.disabled, true)
340
+ const d = p.decorate(list)[0]
341
+ assert.equal(d?.cooling, true)
342
+ assert.ok(d?.until !== undefined, 'session_dead 冷却必须有 until(可恢复)')
340
343
  })
341
344
 
342
345
  test('处置:未知错误第一次就冷却(坏号不留在池里等下一次撞)', () => {
@@ -347,7 +350,7 @@ test('处置:未知错误第一次就冷却(坏号不留在池里等下一
347
350
  assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
348
351
  })
349
352
 
350
- test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(聚合展示)', () => {
353
+ test('decorate:把冷却叠加到插件报的「现在状态」上(聚合展示)', () => {
351
354
  const p = pool()
352
355
  const list: SupplierAccountNow[] = [
353
356
  { uid: 'ok', credits: 5, state: 'ok' },
@@ -359,10 +362,9 @@ test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(
359
362
  const out = p.decorate(list)
360
363
  const by = new Map(out.map((a) => [a.uid, a]))
361
364
  assert.equal(by.get('ok')?.cooling, false)
362
- assert.equal(by.get('ok')?.disabled, false)
363
365
  assert.equal(by.get('cool')?.cooling, true)
364
366
  assert.equal(by.get('cool')?.until !== undefined, true)
365
- assert.equal(by.get('dead')?.disabled, true)
367
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead 连接级冷却也展示为冷却中')
366
368
  // 插件报的字段要原样透出(不能被 decorate 吃掉)
367
369
  assert.equal(by.get('ok')?.credits, 5)
368
370
  })
@@ -375,7 +377,22 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
375
377
  assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
376
378
  const out = p.decorate(list, 'm1')[0]
377
379
  assert.equal(out?.cooling, false)
378
- assert.equal(out?.disabled, false)
380
+ assert.equal(out?.err_count, 0)
381
+ })
382
+
383
+ /**
384
+ * 为什么要有这一条:请求本身非法(如 11133 参数错、11148 tool_call 配对断裂)
385
+ * 对池里**每个号**结果都相同。曾把这类归 rate_limit → 瞬冷 30s,于是一次
386
+ * 带图请求把 money 组合两条腿同时冷掉,之后连纯文本请求也全灭 503。
387
+ * 不惩罚账号,好号才不会替一条坏请求陪葬。
388
+ */
389
+ test('bad_request 不惩罚账号:不冷却、不计数(一次坏请求不该冷掉整池)', () => {
390
+ const p = pool()
391
+ const list = accs(['a'])
392
+ for (let i = 0; i < 10; i++) p.noteFailure('a', 'm1', 'bad_request', 'codebuddy 11148: tool calls do not match')
393
+ assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a', '号必须仍然可用')
394
+ const out = p.decorate(list, 'm1')[0]
395
+ assert.equal(out?.cooling, false, '请求非法不该把号标成冷却')
379
396
  assert.equal(out?.err_count, 0)
380
397
  })
381
398
 
@@ -389,7 +406,7 @@ test('状态表:各 AccountState 的处置符合预期', () => {
389
406
  const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
390
407
  assert.equal(by.get('rate')?.cooling, true)
391
408
  assert.equal(by.get('quota')?.cooling, true)
392
- assert.equal(by.get('dead')?.disabled, true)
409
+ assert.equal(by.get('dead')?.cooling, true, 'session_dead = 连接级冷却')
393
410
  assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
394
411
  assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
395
412
  })
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 账号池 —— dsh-router 核心策略:选号、冷却、禁用、遍历回退。
2
+ * 账号池 —— dsh-router 核心策略:选号、冷却、遍历回退。
3
3
  *
4
4
  * 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里。
5
5
  *
@@ -11,7 +11,8 @@
11
11
  * 失败而对其它模型也不可用,这是错的。
12
12
  *
13
13
  * - 冷却/限流退避/瞬时 → 键 = (supplierId, modelId, uid),只冷「这个号 × 这个模型」
14
- * - 禁用(session_dead) → 键 = uid(登录态问题,该号所有模型都不可用)
14
+ * - 凭证失效(session_dead) → 键 = uid 连接级冷却(登录态问题,该号所有模型都不可用);
15
+ * **没有永久禁用**——403 分不清凭证死活与风控误伤,冷却到期自愈
15
16
  * - 手动冷却(面板) → 键 = uid(管理员整连接暂停)
16
17
  *
17
18
  * 为什么键里要带 supplierId:不同供应商**会有同名的模型 id**(如 traework 与
@@ -19,13 +20,13 @@
19
20
  * 会把跨供应商的同名模型串到一起。尽管本池实例当前是单供应商(loader 每供应商
20
21
  * 一个 AccountPool),键仍显式带 supplierId,语义自明且将来若池合并也安全。
21
22
  *
22
- * 天花板:冷却/禁用都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
+ * 天花板:冷却都是**内存态**,重启归零(与旧插件行为一致)。要跨重启保留
23
24
  * 得落盘,届时加 stateFile 即可,接口不用变。
24
25
  */
25
26
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
26
27
  import { prefixFingerprint } from './prefix-affinity.ts'
27
28
 
28
- /** 各 AccountState 的处置:冷却策略 / 是否禁用 / 是否计入连续错误。 */
29
+ /** 各 AccountState 的处置:冷却策略 / 是否计入连续错误。 */
29
30
  interface Rule {
30
31
  /**
31
32
  * 冷却策略:
@@ -35,14 +36,19 @@ interface Rule {
35
36
  * - `0` 不冷却
36
37
  */
37
38
  cooldown: number | 'transient' | 'backoff'
38
- /** 是否永久禁用(需重新登录才能恢复)。 */
39
- disable: boolean
40
39
  /** 是否计入连续错误(攒够阈值自动冷却)。 */
41
40
  counts: boolean
42
41
  }
43
42
 
44
43
  const MINUTE = 60_000
45
44
 
45
+ /**
46
+ * 凭证失效(session_dead)的连接级冷却时长。
47
+ * 403 = 凭证死 or 风控误伤,上游语义分不清——取「足够长、但不永久」的窗口:
48
+ * 误伤号到点自愈(用户无需重启/重登),真死号每窗口只重试一次,不刷上游。
49
+ */
50
+ const SESSION_DEAD_COOLDOWN_MS = 30 * MINUTE
51
+
46
52
  /**
47
53
  * 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
48
54
  *
@@ -57,16 +63,22 @@ const MINUTE = 60_000
57
63
  * 核心不必再猜文本,更准。
58
64
  */
59
65
  const RULES: Record<AccountState, Rule> = {
60
- ok: { cooldown: 0, disable: false, counts: false },
61
- rate_limit: { cooldown: 'backoff', disable: false, counts: false },
62
- quota: { cooldown: 10 * MINUTE, disable: false, counts: false },
63
- session_dead: { cooldown: 0, disable: true, counts: false },
66
+ ok: { cooldown: 0, counts: false },
67
+ rate_limit: { cooldown: 'backoff', counts: false },
68
+ quota: { cooldown: 10 * MINUTE, counts: false },
69
+ // 凭证失效也只做**连接级冷却**(跨该号所有模型),到期自愈:上游 403 常是
70
+ // 风控/WAF 拦截,与凭证死活分不清(codebuddy 三号被 403 误禁的教训)。
71
+ // 真死号损失 = 每窗口重试一次,可接受;误伤号到点自动回来,无需重启。
72
+ session_dead: { cooldown: SESSION_DEAD_COOLDOWN_MS, counts: false },
64
73
  // 404/服务下线、传输中断、未知:每次都瞬时短冷却(见上方 9router 对齐说明)
65
- unavailable: { cooldown: 'transient', disable: false, counts: false },
66
- transport: { cooldown: 'transient', disable: false, counts: false },
67
- unknown: { cooldown: 'transient', disable: false, counts: false },
74
+ unavailable: { cooldown: 'transient', counts: false },
75
+ transport: { cooldown: 'transient', counts: false },
76
+ unknown: { cooldown: 'transient', counts: false },
68
77
  // 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
69
- no_such_model: { cooldown: 0, disable: false, counts: false },
78
+ no_such_model: { cooldown: 0, counts: false },
79
+ // 请求本身非法(上游拒收这个 payload):同一个请求对池里每个号都会失败,
80
+ // 冷号只会把「这条请求有问题」放大成「这个模型谁都别用」。不惩罚账号。
81
+ bad_request: { cooldown: 0, counts: false },
70
82
  }
71
83
 
72
84
  /**
@@ -112,10 +124,9 @@ interface CooldownEntry {
112
124
  reason: string
113
125
  }
114
126
 
115
- /** 连接级禁用/手动暂停记录(键 = uid)。 */
127
+ /** 连接级冷却/手动暂停记录(键 = uid,跨该号所有模型)。 */
116
128
  interface UidEntry {
117
129
  until: number
118
- disabled: boolean
119
130
  reason: string
120
131
  }
121
132
 
@@ -166,7 +177,7 @@ export class AccountPool {
166
177
  private supplierId: string
167
178
  /** (supplier, model, uid) → 冷却/退避。 */
168
179
  private cooldowns = new Map<string, CooldownEntry>()
169
- /** uid → 连接级禁用 / 手动暂停。 */
180
+ /** uid → 连接级冷却 / 手动暂停。 */
170
181
  private byUid = new Map<string, UidEntry>()
171
182
  private rrCursor = 0
172
183
  /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
@@ -196,33 +207,29 @@ export class AccountPool {
196
207
  return e
197
208
  }
198
209
 
199
- /** 某连接整体状态(禁用/手动暂停)。 */
210
+ /** 某连接整体状态(连接级冷却/手动暂停)。 */
200
211
  private uidState(uid: string): UidEntry {
201
212
  let e = this.byUid.get(uid)
202
213
  if (!e) {
203
- e = { until: 0, disabled: false, reason: '' }
214
+ e = { until: 0, reason: '' }
204
215
  this.byUid.set(uid, e)
205
216
  }
206
217
  return e
207
218
  }
208
219
 
209
- /** 该连接是否被禁用 / 手动整连接暂停中(跨模型生效)。 */
210
- private uidBlocked(uid: string, now: number): { blocked: boolean; disabled: boolean } {
220
+ /** 该连接是否处于连接级冷却/手动暂停中(跨模型生效,到期自愈)。 */
221
+ private uidBlocked(uid: string, now: number): boolean {
211
222
  const e = this.byUid.get(uid)
212
- if (!e) return { blocked: false, disabled: false }
213
- if (e.disabled) return { blocked: true, disabled: true }
214
- if (e.until > now) return { blocked: true, disabled: false }
215
- return { blocked: false, disabled: false }
223
+ return e !== undefined && e.until > now
216
224
  }
217
225
 
218
226
  /**
219
227
  * 给定要试的模型,该连接是否可服务:
220
- * - 未被连接级禁用/手动暂停(跨模型);
228
+ * - 不处于连接级冷却/手动暂停(跨模型,到期自愈);
221
229
  * - 且此 (supplier, model, uid) 冷却单元不在冷却中。
222
230
  */
223
231
  private healthy(uid: string, model: string, now: number): boolean {
224
- const b = this.uidBlocked(uid, now)
225
- if (b.blocked) return false
232
+ if (this.uidBlocked(uid, now)) return false
226
233
  const c = this.cooldowns.get(this.key(model, uid))
227
234
  if (!c) return true
228
235
  return c.until <= now
@@ -367,18 +374,19 @@ export class AccountPool {
367
374
  return `${this.supplierId}${SEP}${modelId}`
368
375
  }
369
376
 
370
- /** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
377
+ /** 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。 */
371
378
  noteFailure(uid: string, modelId: string, state: AccountState, message: string): void {
372
379
  const rule = RULES[state]
373
- // 禁用 / 手动暂停是连接级(登录态问题,跨模型)——但这里只处理规则本身
374
- if (rule.disable) {
375
- // session_dead:整个连接禁用(跨该号所有模型)
380
+ if (rule.cooldown === 0) return // no_such_model 等:不冷不记
381
+ // session_dead 是连接级冷却(登录态问题,跨该号所有模型),到期自愈——
382
+ // 不再有「永久禁用」:403 分不清凭证死活,误伤号不该被钉死
383
+ if (state === 'session_dead') {
376
384
  const e = this.uidState(uid)
377
- e.disabled = true
385
+ const cd = rule.cooldown
386
+ e.until = Math.max(e.until, Date.now() + (typeof cd === 'number' ? cd : 0))
378
387
  e.reason = message
379
388
  return
380
389
  }
381
- if (rule.cooldown === 0) return // no_such_model 等:不冷不记
382
390
  // 冷却/退避:记在 (model, uid) 单元上,不动该号其它模型
383
391
  const e = this.entry(modelId, uid)
384
392
  e.reason = message
@@ -396,7 +404,7 @@ export class AccountPool {
396
404
  e.until = Math.max(e.until, Date.now() + rule.cooldown)
397
405
  }
398
406
 
399
- /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。不解除禁用。 */
407
+ /** 记录一次成功:清零「该模型」的限流退避等级(好号不背历史惩罚)。 */
400
408
  noteSuccess(uid: string, modelId: string): void {
401
409
  const e = this.cooldowns.get(this.key(modelId, uid))
402
410
  if (e) e.backoffLevel = 0
@@ -408,13 +416,12 @@ export class AccountPool {
408
416
  e.until = Date.now() + ms
409
417
  e.reason = reason
410
418
  // 手动暂停通常是想清掉这个号上的累积惩罚:把它的退避等级清零
411
- // (disabled 不可由此解除——需重新登录)
412
419
  for (const [k, ce] of this.cooldowns) {
413
420
  if (k.endsWith(`${SEP}${uid}`)) ce.backoffLevel = 0
414
421
  }
415
422
  }
416
423
 
417
- /** 把核心的冷却/禁用叠加到插件报的「现在状态」上,产出面板态。
424
+ /** 把核心的冷却叠加到插件报的「现在状态」上,产出面板态。
418
425
  *
419
426
  * 冷却现在是 (模型, 连接) 粒度的,而面板状态没有「当前模型」上下文,
420
427
  * 这里默认**聚合**:只要该连接在任一模型上有活跃冷却,就显示 cooling=true
@@ -423,7 +430,6 @@ export class AccountPool {
423
430
  */
424
431
  decorate(accounts: SupplierAccountNow[], modelId?: string): Array<SupplierAccountNow & {
425
432
  cooling: boolean
426
- disabled: boolean
427
433
  err_count: number
428
434
  until?: string
429
435
  reason?: string
@@ -431,13 +437,12 @@ export class AccountPool {
431
437
  const now = Date.now()
432
438
  return accounts.map((a) => {
433
439
  const u = this.byUid.get(a.uid)
434
- const disabled = u?.disabled ?? false
435
- // 连接级手动暂停或禁用也算不可服务;但 disabled 单列,冷却不算它
436
- const manualCooling = u !== undefined && !u.disabled && u.until > now
440
+ // 连接级冷却(session_dead 处置 / 手动暂停)也算 cooling
441
+ const uidCooling = u !== undefined && u.until > now
437
442
 
438
- let cooling = manualCooling
439
- let maxUntil = manualCooling ? u!.until : 0
440
- let reason = manualCooling ? u!.reason : undefined
443
+ let cooling = uidCooling
444
+ let maxUntil = uidCooling ? u!.until : 0
445
+ let reason = uidCooling ? u!.reason : undefined
441
446
  let err = 0
442
447
  // 遍历本连接在所有模型上的冷却单元(或仅指定模型)
443
448
  for (const [k, ce] of this.cooldowns) {
@@ -459,7 +464,6 @@ export class AccountPool {
459
464
  return {
460
465
  ...a,
461
466
  cooling,
462
- disabled,
463
467
  err_count: err,
464
468
  until: cooling && maxUntil > 0 ? new Date(maxUntil).toISOString() : undefined,
465
469
  reason: reason !== undefined && reason !== '' ? reason : undefined,