dsh-router-core 0.3.12 → 0.3.14

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.
@@ -4,7 +4,11 @@
4
4
  * 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
5
5
  * 由现有路由按组合策略命中供应商模型。
6
6
  *
7
- * 纯文本流:组合为文本模型,本 adapter 不处理图片附件。
7
+ * 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
8
+ * `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
9
+ * 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
10
+ * 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
11
+ * 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
8
12
  */
9
13
  import {
10
14
  EMPTY_RESPONSE_CODE,
@@ -17,10 +21,34 @@ import {
17
21
  type LlmModelInfo,
18
22
  type LlmReasoningEffortInfo,
19
23
  type LlmResolvedModelInfo,
24
+ type ModelModality,
20
25
  type StreamChunk,
21
26
  } from '@deepseek-ai/dsh-llm'
22
27
  import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
23
28
 
29
+ /**
30
+ * dsh-attachment 的最小本地切面:adapter 只依赖「读一张图片的请求版本」
31
+ * 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
32
+ * devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
33
+ * 宿主注入真实 store 时天然满足。
34
+ */
35
+ export interface RouterAttachmentStore {
36
+ readImageRequest(
37
+ ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
38
+ policy: { maxPixels: number; maxBytes: number },
39
+ signal?: AbortSignal,
40
+ ): Promise<{ mediaType: string; data: Uint8Array }>
41
+ }
42
+
43
+ /** 一张 image 块的 attachment ref 的最小形状(value 类型,够用即可)。 */
44
+ interface RouterImgLike {
45
+ attachmentId: string
46
+ mediaType: string
47
+ bytes: number
48
+ width: number
49
+ height: number
50
+ }
51
+
24
52
  /** 模型目录来源:组合。 */
25
53
  export interface RouterAdapterSource {
26
54
  /**
@@ -33,15 +61,24 @@ export interface RouterAdapterSource {
33
61
  }
34
62
 
35
63
  /**
36
- * 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
64
+ * 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
65
+ *
66
+ * `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
67
+ * 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
68
+ * one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
69
+ * 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
37
70
  *
38
- * `system` 必须由调用方从 `options.system` 传进来:agent-loop 把系统提示词
39
- * 放在 **options.system 这个独立槽位**,不放进 messages(见 dsh-llm
40
- * types.d.ts 的 `system?: string` —— 注释原话 "adapters map to the
41
- * provider's system slot")。不读这个槽位,模型每一轮都拿不到身份/规则/
42
- * 工具用法约束,且**不会报错**,只是行为悄悄降级。
71
+ * 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
72
+ * **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
73
+ * 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
74
+ * dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
75
+ * messages 里的 system 消息)。
76
+ *
77
+ * 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
78
+ * base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
79
+ * **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
43
80
  */
44
- function wireMessages(options: GenerateOptions, system?: string): Array<Record<string, unknown>> {
81
+ async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
45
82
  const out: Array<Record<string, unknown>> = []
46
83
  if (typeof system === 'string' && system !== '') {
47
84
  out.push({ role: 'system', content: system })
@@ -70,9 +107,16 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
70
107
  continue
71
108
  }
72
109
  // user / tool-result
73
- const text = flattenText(message.content)
74
110
  const toolResults = message.content.filter((b) => b.type === 'tool-result')
75
- if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
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 })
119
+ }
76
120
  for (const result of toolResults) {
77
121
  // 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
78
122
  // 以为工具真的打印了那句话(9router 也是补 content: "")。
@@ -82,9 +126,73 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
82
126
  return out
83
127
  }
84
128
 
85
- /** 组装 wire 请求体。 */
86
- function wireRequest(options: GenerateOptions): Record<string, unknown> {
87
- const messages = wireMessages(options, options.system)
129
+ /**
130
+ * 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
131
+ * 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
132
+ * attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
133
+ */
134
+ async function contentParts(
135
+ blocks: ReadonlyArray<{ type: string }>,
136
+ attachments: RouterAttachmentStore | undefined,
137
+ signal: AbortSignal | undefined,
138
+ ): Promise<Array<Record<string, unknown>>> {
139
+ const parts: Array<Record<string, unknown>> = []
140
+ for (const block of blocks) {
141
+ if (block.type === 'text') {
142
+ const text = (block as { text?: string }).text ?? ''
143
+ if (text.length > 0) parts.push({ type: 'text', text })
144
+ continue
145
+ }
146
+ if (block.type === 'image') {
147
+ const ref = (block as unknown as { attachment: RouterImgLike }).attachment
148
+ parts.push(...(await imageParts(ref, attachments, signal)))
149
+ continue
150
+ }
151
+ if (block.type === 'tool-result') {
152
+ parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal)))
153
+ }
154
+ // 其它块(reasoning/tool-call)不属于 user content,忽略
155
+ }
156
+ return parts
157
+ }
158
+
159
+ /** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
160
+ async function imageParts(
161
+ ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
162
+ attachments: RouterAttachmentStore | undefined,
163
+ signal: AbortSignal | undefined,
164
+ ): Promise<Array<Record<string, unknown>>> {
165
+ if (attachments === undefined) return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
166
+ try {
167
+ const img = await attachments.readImageRequest(
168
+ { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
169
+ { maxPixels: 64e4, maxBytes: 1024 * 1024 },
170
+ signal,
171
+ )
172
+ return [{ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` } }]
173
+ } catch {
174
+ return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
175
+ }
176
+ }
177
+
178
+ /** Uint8Array → base64(browser + node 双环境安全,不依赖 Buffer 全局)。 */
179
+ function bytesToBase64(bytes: Uint8Array): string {
180
+ let bin = ''
181
+ const chunk = 0x8000
182
+ for (let i = 0; i < bytes.length; i += chunk) {
183
+ bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
184
+ }
185
+ return btoa(bin)
186
+ }
187
+
188
+ /** 读不到图片字节时的稳定占位文本。 */
189
+ function imagePlaceholder(attachmentId: string): string {
190
+ return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`
191
+ }
192
+
193
+ /** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
194
+ async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined): Promise<Record<string, unknown>> {
195
+ const messages = await wireMessages(options, options.system, attachments)
88
196
  const body: Record<string, unknown> = {
89
197
  model: options.model,
90
198
  messages,
@@ -130,6 +238,15 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
130
238
  return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
131
239
  }
132
240
 
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
+
133
250
  interface OpenBlock {
134
251
  index: number
135
252
  kind: 'text' | 'reasoning' | 'tool-call'
@@ -373,11 +490,13 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
373
490
  export class RouterAdapter extends LlmAdapter {
374
491
  private readonly baseURL: string
375
492
  private readonly source: RouterAdapterSource
493
+ private readonly resolveAttachments: () => RouterAttachmentStore | undefined
376
494
 
377
- constructor(baseURL: string, source: RouterAdapterSource) {
495
+ constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined) {
378
496
  super()
379
497
  this.baseURL = baseURL
380
498
  this.source = source
499
+ this.resolveAttachments = resolveAttachments ?? (() => undefined)
381
500
  }
382
501
 
383
502
  providerInfo(provider: string): { id: string; name: string } {
@@ -396,6 +515,16 @@ export class RouterAdapter extends LlmAdapter {
396
515
  return out
397
516
  }
398
517
 
518
+ /**
519
+ * 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
520
+ * - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
521
+ * `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
522
+ * `projectImagesForTextModel` 也不会把图片块投影成占位文本。
523
+ * - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
524
+ * 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
525
+ */
526
+ private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text', 'image']
527
+
399
528
  async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
400
529
  const combos = await this.source.comboModels()
401
530
  const found = combos.find((m) => m.id === model || m.name === model)
@@ -407,13 +536,14 @@ export class RouterAdapter extends LlmAdapter {
407
536
  provider: 'router',
408
537
  id: model,
409
538
  name,
539
+ inputModalities: RouterAdapter.INPUT_MODALITIES,
410
540
  ...(found?.contextWindow !== undefined ? { context: { contextWindow: found.contextWindow } } : {}),
411
541
  reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
412
542
  }
413
543
  }
414
544
 
415
545
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
416
- const body = wireRequest(options)
546
+ const body = await wireRequest(options, this.resolveAttachments())
417
547
  const controller = new AbortController()
418
548
  const onAbort = (): void => controller.abort()
419
549
  options.signal?.addEventListener('abort', onAbort, { once: true })
@@ -161,6 +161,132 @@ test('【块】多号间长期均衡:块大小有界 → 请求数均分', ()
161
161
  assert.equal(count['c'], 4)
162
162
  })
163
163
 
164
+ // ============ 前缀亲和(docs/pool-sticky-block.md §8 的根治) ============
165
+
166
+ /** 构造一次请求的 messages(前缀 = system + 首条用户消息)。 */
167
+ const msgs = (sessionSeed: string, turns = 1): unknown => [
168
+ { role: 'system', content: `shared agent prompt` },
169
+ { role: 'user', content: `${sessionSeed} ${'x'.repeat(40)}` },
170
+ ...Array.from({ length: turns - 1 }, () => ({ role: 'assistant', content: 'ok' })),
171
+ ]
172
+
173
+ test('【亲和】同会话粘同一号:指纹相同 → 每次都返回同一个号', () => {
174
+ const p = pool()
175
+ const list = accs(['a', 'b', 'c'])
176
+ const m = msgs('session-1')
177
+ const first = p.pick(list, [], 'round-robin', 'm1', m)
178
+ // 连续多轮(会话增长,但前缀不变)→ 永远是第一个号
179
+ for (let i = 0; i < 10; i++) {
180
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('session-1', i + 2)), first)
181
+ }
182
+ })
183
+
184
+ test('【亲和】不同会话散到不同号:新指纹按游标分发', () => {
185
+ const p = pool()
186
+ const list = accs(['a', 'b'])
187
+ const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
188
+ const s2 = p.pick(list, [], 'round-robin', 'm1', msgs('s2'))
189
+ assert.notEqual(s1, s2, '两个会话应落到不同的号(缓存空间隔离)')
190
+ // 各自回访仍粘各自的号
191
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 5)), s1)
192
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s2', 5)), s2)
193
+ })
194
+
195
+ test('【亲和】会话增长指纹稳定:前缀相同、轮数不同 → 同一号', () => {
196
+ const p = pool()
197
+ const list = accs(['a', 'b'])
198
+ const s1 = p.pick(list, [], 'round-robin', 'm1', msgs('s1'))
199
+ // 真实会话每轮 messages 变长,但前 3 条不变 → 指纹不变
200
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 30)), s1)
201
+ })
202
+
203
+ test('【亲和】绑定的号冷却 → 会话让位,换健康号', () => {
204
+ const p = pool()
205
+ const list = accs(['a', 'b'])
206
+ const m = msgs('s1')
207
+ const first = p.pick(list, [], 'round-robin', 'm1', m)!
208
+ p.noteFailure(first, 'm1', 'rate_limit', '429')
209
+ // 绑定的号不可用 → 不能返回它(挂了),应落到别的号
210
+ const second = p.pick(list, [], 'round-robin', 'm1', m)
211
+ assert.notEqual(second, first)
212
+ // 且之后粘在新号上(绑定已更新)
213
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), second)
214
+ })
215
+
216
+ test('【亲和】绑定的号降权(缓存无效)→ 跳过它', () => {
217
+ const p = pool()
218
+ const list = accs(['a', 'b'])
219
+ const m = msgs('s1')
220
+ const first = p.pick(list, [], 'round-robin', 'm1', m)!
221
+ // 该号累计 48 次全 miss → 判定缓存无效
222
+ serveMiss(p, first, 48)
223
+ const second = p.pick(list, [], 'round-robin', 'm1', m)
224
+ assert.notEqual(second, first, '降权中的号不应被亲和继续选中')
225
+ })
226
+
227
+ test('【亲和】messages 拿不到 → 回退块轮询(旧行为不破坏)', () => {
228
+ const p = pool()
229
+ const list = accs(['a', 'b'])
230
+ // 不传 messages → 走块驻留
231
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
232
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'a')
233
+ serveHits(p, 'a', 16)
234
+ assert.equal(p.pick(list, [], 'round-robin', 'm1'), 'b')
235
+ // 传了但算不出指纹(空/坏结构)→ 同样回退
236
+ assert.equal(p.pick(list, [], 'round-robin', 'm2', []), 'a')
237
+ assert.equal(p.pick(list, [], 'round-robin', 'm2', [{ nope: 1 }]), 'a')
238
+ })
239
+
240
+ test('【亲和】绑定表按模型隔离:冷却是 (model, uid) 粒度,不串模型', () => {
241
+ const p = pool()
242
+ const list = accs(['a', 'b'])
243
+ const m = msgs('s1')
244
+ const m1 = p.pick(list, [], 'round-robin', 'm1', m)
245
+ // rrCursor 跨模型共享(与旧块轮询一致):m1 的新会话消耗 a 后,m2 的新会话分到 b
246
+ const m2 = p.pick(list, [], 'round-robin', 'm2', m)
247
+ assert.equal(m1, 'a')
248
+ assert.equal(m2, 'b')
249
+ // 各自回访粘各自的号(绑定表按模型分开,m1 的 a 不会盖掉 m2 的 b)
250
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('s1', 9)), 'a')
251
+ assert.equal(p.pick(list, [], 'round-robin', 'm2', msgs('s1', 9)), 'b')
252
+ p.noteFailure('a', 'm1', 'rate_limit', '429') // 只冷 (m1, a)
253
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', m), 'b', 'm1 上让位')
254
+ assert.equal(p.pick(list, [], 'round-robin', 'm2', m), 'b', 'm2 不受影响,仍粘 b')
255
+ })
256
+
257
+ test('【亲和】绑定表 LRU 封顶:超上限不泄漏,旧指纹被挤掉', () => {
258
+ const p = pool()
259
+ const list = accs(['a', 'b', 'c'])
260
+ const first = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))!
261
+ // 灌 600 个新会话(> AFFINITY_MAX=512)→ 最老的 s0 被挤出
262
+ for (let i = 0; i < 600; i++) p.pick(list, [], 'round-robin', 'm1', msgs(`flood-${i}`))
263
+ const again = p.pick(list, [], 'round-robin', 'm1', msgs('s0'))
264
+ // 被挤掉后重新分发——只要不是内存无限增长即可(uid 合法性由 pool 保证)
265
+ assert.equal(list.some((a) => a.uid === again), true)
266
+ // 活跃会话(每次都访问)不被挤掉
267
+ const hot = p.pick(list, [], 'round-robin', 'm1', msgs('hot'))!
268
+ for (let i = 0; i < 600; i++) {
269
+ p.pick(list, [], 'round-robin', 'm1', msgs(`flood2-${i}`))
270
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', msgs('hot')), hot, '活跃会话的绑定应被 LRU 刷新保留')
271
+ }
272
+ })
273
+
274
+ test('【亲和】多模态分片 content 也能算指纹(文本片段参与,base64 不炸)', () => {
275
+ const p = pool()
276
+ const list = accs(['a', 'b'])
277
+ const multi = [
278
+ { role: 'user', content: [{ type: 'text', text: 'look at this' }, { type: 'image_url', image_url: 'data:image/png;base64,AAAA' }] },
279
+ ]
280
+ const first = p.pick(list, [], 'round-robin', 'm1', multi)
281
+ assert.equal(p.pick(list, [], 'round-robin', 'm1', multi), first)
282
+ // 文本不同的另一个会话 → 不同指纹
283
+ const other = [
284
+ { role: 'user', content: [{ type: 'text', text: 'different' }] },
285
+ ]
286
+ assert.notEqual(p.pick(list, [], 'round-robin', 'm1', other), first, '文本不同应散开')
287
+ })
288
+
289
+
164
290
  // ============ 核心新语义:冷却按 (模型, 连接) 分号 ============
165
291
 
166
292
  test('【颗粒度】连接在模型 A 失败,只冷 (A, 该连接),不影响它调模型 B', () => {
@@ -23,6 +23,7 @@
23
23
  * 得落盘,届时加 stateFile 即可,接口不用变。
24
24
  */
25
25
  import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
26
+ import { prefixFingerprint } from './prefix-affinity.ts'
26
27
 
27
28
  /** 各 AccountState 的处置:冷却策略 / 是否禁用 / 是否计入连续错误。 */
28
29
  interface Rule {
@@ -95,6 +96,15 @@ const BLOCK_MAX = 48
95
96
  /** 块轮询:判定「缓存无效」后的跳过时长。 */
96
97
  const BLOCK_DEMOTE_MS = 10 * MINUTE
97
98
 
99
+ /**
100
+ * 前缀亲和:指纹 → 号 的绑定记忆上限。
101
+ *
102
+ * 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
103
+ * **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
104
+ * 单号池(<= 几十个会话)绰绰有余。
105
+ */
106
+ const AFFINITY_MAX = 512
107
+
98
108
  /** 冷却记录(键 = (supplier, model, uid))。 */
99
109
  interface CooldownEntry {
100
110
  until: number
@@ -124,6 +134,20 @@ interface BlockState {
124
134
  hits: number
125
135
  }
126
136
 
137
+ /**
138
+ * 某号在某模型上的缓存质量样本(键 = (supplier, model, uid))。
139
+ *
140
+ * 与「驻留」解耦:开启前缀亲和后多个会话各绑各的号交错服务,驻留概念只在
141
+ * 无亲和的回退路径上存在;而「这个号的缓存到底热不热」是**号本身的属性**,
142
+ * 必须跨驻留累计——否则每次换驻留就清零,永远攒不到 BLOCK_MAX 次判据。
143
+ */
144
+ interface Sample {
145
+ /** 已服务的成功请求数。 */
146
+ served: number
147
+ /** 命中缓存(cachedTokens > 0)的请求数。 */
148
+ hits: number
149
+ }
150
+
127
151
  /** 「缓存无效」降权记录(键 = (supplier, model, uid))。 */
128
152
  interface DemoteEntry {
129
153
  until: number
@@ -145,10 +169,14 @@ export class AccountPool {
145
169
  /** uid → 连接级禁用 / 手动暂停。 */
146
170
  private byUid = new Map<string, UidEntry>()
147
171
  private rrCursor = 0
148
- /** (supplier, model) → 块轮询驻留状态。 */
149
- private blocks = new Map<string, BlockState>()
172
+ /** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
173
+ private blocks = new Map<string, string>()
174
+ /** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
175
+ private samples = new Map<string, Sample>()
150
176
  /** (supplier, model, uid) → 缓存无效降权。 */
151
177
  private demoted = new Map<string, DemoteEntry>()
178
+ /** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
179
+ private affinity = new Map<string, Map<string, string>>()
152
180
 
153
181
  constructor(supplierId = '') {
154
182
  this.supplierId = supplierId
@@ -203,22 +231,28 @@ export class AccountPool {
203
231
  /**
204
232
  * 按策略为「某个模型」选一个健康账号。
205
233
  *
206
- * round-robin 走**块轮询**:一次轮转内连续服务若干请求,而不是每请求换号。
207
- * 设计见 docs/pool-sticky-block.md。要点:
208
- * - 留守 = `计数 < N OR 命中率 < M`(OR,用户拍板):
209
- * 命中率达标(缓存热了)→ 换号保均衡;计数到 N → 换号止损。
210
- * 单号最多连续服务 N 次,不会因命中率低而死守到 N_max(打架场景关键)。
211
- * - 切号因子的思路:命中率是**留守**条件不是切号条件。反接会正反馈死锁
212
- * (切→cold miss→命中率 0%→触发切号→…),见文档 §3。
213
- * - `N_max` 绝对兜底:命中率永远不达标的号不能无限驻留(防死锁 + 防均衡归零)。
234
+ * round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
235
+ *
236
+ * 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
237
+ * 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
238
+ * 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
239
+ * 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
240
+ * 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
241
+ * 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
242
+ * 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
243
+ * 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
244
+ *
245
+ * 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
246
+ * 设计见 docs/pool-sticky-block.md §8。
214
247
  *
215
248
  * @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
216
249
  * @param poolOrder 用户在面板拖出来的顺序(核心管)
217
- * @param strategy fallback 取第一个健康 / round-robin 块轮转健康号
250
+ * @param strategy fallback 取第一个健康 / round-robin 前缀亲和
218
251
  * @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
252
+ * @param messages 请求体的 messages(可选;给了才能算前缀指纹)
219
253
  * @returns 选中的 uid;无健康账号返回 undefined
220
254
  */
221
- pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined {
255
+ pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined {
222
256
  const now = Date.now()
223
257
  const byUid = new Map(accounts.map((a) => [a.uid, a]))
224
258
  // 池顺序优先,未配置的按插件自然顺序追加
@@ -229,21 +263,50 @@ export class AccountPool {
229
263
  const healthy = ordered.filter((uid) => this.healthy(uid, modelId, now))
230
264
  if (healthy.length === 0) return undefined
231
265
  if (strategy !== 'round-robin') return healthy[0]
232
- // 缓存无效降权:驻留满 N_max 命中率仍不达标的号,跳过它(可到期恢复)
266
+ // 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
233
267
  const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now))
234
268
  const pool = usable.length > 0 ? usable : healthy
269
+ // 前缀亲和:指纹有绑定且该号仍可用 → 直接复用(不进驻留逻辑)
270
+ const fp = messages === undefined ? '' : prefixFingerprint(messages)
271
+ if (fp !== '' && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? '')) {
272
+ return this.affinity.get(this.blockKey(modelId))!.get(fp)!
273
+ }
235
274
  const block = this.blocks.get(this.blockKey(modelId))
236
- if (block !== undefined && pool.includes(block.uid) && this.blockKeeps(block)) {
237
- return block.uid
275
+ if (fp === '' && block !== undefined && pool.includes(block) && this.blockKeeps(modelId, block)) {
276
+ return block
238
277
  }
239
- // 块满/块达标/块不存在 → 前进游标。从当前号之后开始找,保证轮转顺序稳定。
240
- const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block.uid) + 1)
278
+ // 无亲和(或块满/块达标)→ 前进游标。从当前号之后开始找,保证轮转顺序稳定。
279
+ const from = block === undefined ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)
241
280
  const uid = pool[from % pool.length]!
242
281
  this.rrCursor = (pool.indexOf(uid) + 1) % pool.length
243
- this.blocks.set(this.blockKey(modelId), { uid, served: 0, hits: 0 })
282
+ this.blocks.set(this.blockKey(modelId), uid)
283
+ if (fp !== '') {
284
+ this.bind(modelId, fp, uid)
285
+ } else {
286
+ // 新驻留 = 新观察窗口(旧行为:块计数从 0 开始)。样本是跨驻留累计的,
287
+ // 不清的话上一个大样本会立刻把新驻留判走。
288
+ this.samples.delete(this.key(modelId, uid))
289
+ }
244
290
  return uid
245
291
  }
246
292
 
293
+ /** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
294
+ private bind(modelId: string, fp: string, uid: string): void {
295
+ const k = this.blockKey(modelId)
296
+ let m = this.affinity.get(k)
297
+ if (m === undefined) {
298
+ m = new Map()
299
+ this.affinity.set(k, m)
300
+ }
301
+ // 已存在则先删再 set:Map 的插入序即 LRU 序,重插等于刷新到最新
302
+ m.delete(fp)
303
+ m.set(fp, uid)
304
+ if (m.size > AFFINITY_MAX) {
305
+ const oldest = m.keys().next().value
306
+ if (oldest !== undefined) m.delete(oldest)
307
+ }
308
+ }
309
+
247
310
  /** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
248
311
  private isDemoted(uid: string, modelId: string, now: number): boolean {
249
312
  const e = this.demoted.get(this.key(modelId, uid))
@@ -251,7 +314,7 @@ export class AccountPool {
251
314
  }
252
315
 
253
316
  /**
254
- * 块是否还应驻留在当前号上。
317
+ * 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
255
318
  *
256
319
  * OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
257
320
  * 单一账号内部:一个号先被轮到(冷启动/热),然后
@@ -261,39 +324,40 @@ export class AccountPool {
261
324
  *
262
325
  * 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
263
326
  * 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
264
- *
265
- * 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
266
- * 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
267
- * 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
268
327
  */
269
- private blockKeeps(block: BlockState): boolean {
270
- if (block.served >= BLOCK_MAX) return false // 轮次过多 → 让位(兜底)
271
- const ratio = block.served === 0 ? 0 : block.hits / block.served
272
- return block.served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO // 计数<N OR 命中率<M
328
+ private blockKeeps(modelId: string, uid: string): boolean {
329
+ const s = this.samples.get(this.key(modelId, uid))
330
+ const served = s?.served ?? 0
331
+ if (served >= BLOCK_MAX) return false // 轮次过多 → 让位(兜底)
332
+ const ratio = served === 0 ? 0 : (s?.hits ?? 0) / served
333
+ return served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO // 计数<N OR 命中率<M
273
334
  }
274
335
 
275
336
  /**
276
- * 记录一次成功请求的缓存命中情况(块轮询的反馈信号)。
337
+ * 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
277
338
  *
278
- * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错切。
339
+ * 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
340
+ * 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
341
+ * 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
279
342
  * @param uid 实际服务的号
280
343
  * @param modelId 模型(块粒度)
281
344
  * @param cachedTokens 上游报的命中 token 数;0 = 全量重算
282
345
  */
283
346
  noteCache(uid: string, modelId: string, cachedTokens: number): void {
284
- const k = this.blockKey(modelId)
285
- const block = this.blocks.get(k)
286
- // 块已被别处重置/换号(如故障切走)→ 这次结果不属于当前块,忽略
287
- if (block === undefined || block.uid !== uid) return
288
- block.served += 1
289
- if (cachedTokens > 0) block.hits += 1
290
- // 驻留满 N_max 仍不达标 → 判定缓存无效,降权一段时间(可恢复)
291
- if (block.served >= BLOCK_MAX && block.hits / block.served < BLOCK_HIT_RATIO) {
292
- this.demoted.set(this.key(modelId, uid), { until: Date.now() + BLOCK_DEMOTE_MS })
347
+ const k = this.key(modelId, uid)
348
+ const s = this.samples.get(k) ?? { served: 0, hits: 0 }
349
+ s.served += 1
350
+ if (cachedTokens > 0) s.hits += 1
351
+ this.samples.set(k, s)
352
+ // 累计满 N_max 仍不达标 → 判定缓存无效,降权一段时间(可恢复)
353
+ if (s.served >= BLOCK_MAX && s.hits / s.served < BLOCK_HIT_RATIO) {
354
+ this.demoted.set(k, { until: Date.now() + BLOCK_DEMOTE_MS })
355
+ // 降权后样本重置:冷却结束重新观察,而不是带着旧判决立刻再降
356
+ this.samples.delete(k)
293
357
  }
294
358
  }
295
359
 
296
- /** 故障切走时作废当前块:换号后前缀归属变了,块计数不能沿用。 */
360
+ /** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
297
361
  dropBlock(modelId: string): void {
298
362
  this.blocks.delete(this.blockKey(modelId))
299
363
  }
@@ -78,6 +78,18 @@ function isTransient(state: AccountState): boolean {
78
78
  return state === 'transport' || state === 'unavailable'
79
79
  }
80
80
 
81
+ /**
82
+ * 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
83
+ * undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
84
+ */
85
+ function reqMessages(rawBody: string): unknown {
86
+ try {
87
+ return (JSON.parse(rawBody) as { messages?: unknown }).messages
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }
92
+
81
93
  /** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
82
94
  function sinkRes(): ServerResponse & { status(): number; body(): string } {
83
95
  let status = 200
@@ -1006,9 +1018,12 @@ export class Router {
1006
1018
  }
1007
1019
  // 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
1008
1020
  // 不排除试过的就会原地打转——死循环等于整个服务挂住。
1021
+ // messages 只取一次:前缀亲和用它算指纹(重试换号时指纹必须一致,
1022
+ // 否则同一会话的两次尝试会绑到不同的号上)。
1023
+ const messages = reqMessages(req.rawBody)
1009
1024
  const tried = new Set<string>()
1010
1025
  for (;;) {
1011
- const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model)
1026
+ const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages)
1012
1027
  if (uid === undefined) return false
1013
1028
  tried.add(uid)
1014
1029
  const r = await s.chatOnce(uid, req.lv ?? 'auto', req)