dsh-router-core 0.4.4 → 0.4.6
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.
- package/LICENSE +0 -0
- package/README.md +0 -0
- package/cordis.patch.yml +0 -0
- package/docs/ext.md +0 -0
- package/docs/pool-sticky-block.md +59 -1
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +22 -3
- package/lib/client-registry.js +24 -14
- package/lib/client.js +24 -14
- package/lib/ext-prompt/locale/en.json +0 -0
- package/lib/ext-prompt/locale/zh.json +0 -0
- package/lib/ext-prompt/package.json +0 -0
- package/lib/ext-test/locale/en.json +0 -0
- package/lib/ext-test/locale/zh.json +0 -0
- package/lib/ext-test/package.json +0 -0
- package/lib/index.js +101 -15
- package/lib/suppliers/nvidia/locale/en.json +0 -0
- package/lib/suppliers/nvidia/locale/zh.json +0 -0
- package/lib/suppliers/nvidia/package.json +0 -0
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/suppliers/opencode/locale/en.json +0 -0
- package/lib/suppliers/opencode/locale/zh.json +0 -0
- package/lib/suppliers/opencode/package.json +0 -0
- package/lib/suppliers/openrouter/locale/en.json +0 -0
- package/lib/suppliers/openrouter/locale/zh.json +0 -0
- package/lib/suppliers/openrouter/package.json +0 -0
- package/lib/types/router/account-pool.d.ts +30 -3
- package/lib/types/router/index.d.ts +25 -0
- package/lib/types/router/types.d.ts +16 -4
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +19 -0
- package/lib/types/suppliers/contract.d.ts +7 -1
- package/package.json +1 -1
- package/src/client/CheckinCard.tsx +0 -0
- package/src/client/CombosTab.tsx +0 -0
- package/src/client/EndpointTab.tsx +0 -0
- package/src/client/ExtDetail.tsx +0 -0
- package/src/client/ExtTab.tsx +0 -0
- package/src/client/ExtTestPanel.tsx +0 -0
- package/src/client/LastHitDock.tsx +0 -0
- package/src/client/Modal.tsx +0 -0
- package/src/client/RouterComponentsSection.tsx +0 -0
- package/src/client/RouterView.tsx +0 -0
- package/src/client/StatsTab.tsx +0 -0
- package/src/client/SupplierDetail.tsx +1 -5
- package/src/client/account-badge.test.ts +37 -0
- package/src/client/account-badge.ts +24 -0
- package/src/client/comp-button-scope.test.ts +0 -0
- package/src/client/comp-panel-layout.test.ts +0 -0
- package/src/client/env.d.ts +0 -0
- package/src/client/ext-panels.test.ts +0 -0
- package/src/client/ext-panels.ts +0 -0
- package/src/client/index.tsx +0 -0
- package/src/client/last-hit-store.ts +0 -0
- package/src/client/model-choice.test.ts +0 -0
- package/src/client/model-choice.ts +0 -0
- package/src/client/model-hint-copy.test.ts +0 -0
- package/src/client/model-hint-copy.ts +0 -0
- package/src/client/router-components.test.ts +0 -0
- package/src/client/router-components.ts +0 -0
- package/src/client/router.css +0 -0
- package/src/client/settings-nav-icon.ts +0 -0
- package/src/client/settings-section.tsx +0 -0
- package/src/client/sidebar-entry.ts +0 -0
- package/src/client/supplier-visibility.test.ts +0 -0
- package/src/client/supplier-visibility.ts +0 -0
- package/src/client/workspace-controller.ts +0 -0
- package/src/client/workspace-mount.tsx +0 -0
- package/src/credential-store.ts +0 -0
- package/src/data-dir.test.ts +0 -0
- package/src/data-dir.ts +0 -0
- package/src/ext/contract.ts +0 -0
- package/src/ext/store.test.ts +0 -0
- package/src/ext/store.ts +0 -0
- package/src/ext-prompt/assembly.test.ts +0 -0
- package/src/ext-prompt/content.ts +0 -0
- package/src/ext-prompt/ext-prompt.test.ts +0 -0
- package/src/ext-prompt/index.ts +0 -0
- package/src/ext-prompt/locale/en.json +0 -0
- package/src/ext-prompt/locale/zh.json +0 -0
- package/src/ext-prompt/logo.svg +0 -0
- package/src/ext-prompt/logo.ts +0 -0
- package/src/ext-prompt/mount.test.ts +0 -0
- package/src/ext-prompt/mount.ts +0 -0
- package/src/ext-prompt/plugin.ts +0 -0
- package/src/ext-prompt/render.test.ts +0 -0
- package/src/ext-prompt/render.ts +0 -0
- package/src/ext-prompt/row.json +0 -0
- package/src/ext-route.test.ts +0 -0
- package/src/ext-test/ext-test.test.ts +0 -0
- package/src/ext-test/index.ts +0 -0
- package/src/ext-test/locale/en.json +0 -0
- package/src/ext-test/locale/zh.json +0 -0
- package/src/ext-test/plugin.ts +0 -0
- package/src/ext-test/row.json +0 -0
- package/src/host-base-url.test.ts +0 -0
- package/src/host-base-url.ts +0 -0
- package/src/host-version.test.ts +0 -0
- package/src/host-version.ts +0 -0
- package/src/index.ts +20 -6
- package/src/keys.ts +0 -0
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/router/account-pool.test.ts +109 -0
- package/src/router/account-pool.ts +52 -9
- package/src/router/combo-change.test.ts +125 -0
- package/src/router/disconnect-leak.test.ts +0 -0
- package/src/router/first-byte-budget.test.ts +0 -0
- package/src/router/index.ts +44 -4
- package/src/router/models-cache.test.ts +0 -0
- package/src/router/precise-dispatch.test.ts +0 -0
- package/src/router/prefix-affinity.ts +0 -0
- package/src/router/supplier-switch.test.ts +91 -0
- package/src/router/types.ts +16 -4
- package/src/router/usage-store.ts +4 -2
- package/src/router/usage-tokens.ts +0 -0
- package/src/router/usage.test.ts +0 -0
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +28 -0
- package/src/supplier-config.test.ts +0 -0
- package/src/supplier-config.ts +0 -0
- package/src/suppliers/contract.ts +7 -2
- package/src/suppliers/http-state.test.ts +0 -0
- package/src/suppliers/http-state.ts +0 -0
- package/src/suppliers/loader.test.ts +53 -0
- package/src/suppliers/loader.ts +0 -0
- package/src/suppliers/nvidia/index.ts +0 -0
- package/src/suppliers/nvidia/locale/en.json +0 -0
- package/src/suppliers/nvidia/locale/zh.json +0 -0
- package/src/suppliers/nvidia/plugin.ts +0 -0
- package/src/suppliers/nvidia/row.json +0 -0
- package/src/suppliers/opencode/index.ts +0 -0
- package/src/suppliers/opencode/locale/en.json +0 -0
- package/src/suppliers/opencode/locale/zh.json +0 -0
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
- package/src/suppliers/opencode/row.json +0 -0
- package/src/suppliers/openrouter/index.ts +0 -0
- package/src/suppliers/openrouter/locale/en.json +0 -0
- package/src/suppliers/openrouter/locale/zh.json +0 -0
- package/src/suppliers/openrouter/plugin.ts +0 -0
- package/src/suppliers/openrouter/row.json +0 -0
- package/src/suppliers/probe.test.ts +0 -0
- package/src/suppliers/probe.ts +0 -0
- package/src/suppliers/registry.test.ts +0 -0
- package/src/suppliers/registry.ts +0 -0
- package/src/types.d.ts +0 -0
package/src/ext-prompt/row.json
CHANGED
|
File without changes
|
package/src/ext-route.test.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/ext-test/index.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/ext-test/plugin.ts
CHANGED
|
File without changes
|
package/src/ext-test/row.json
CHANGED
|
File without changes
|
|
File without changes
|
package/src/host-base-url.ts
CHANGED
|
File without changes
|
package/src/host-version.test.ts
CHANGED
|
File without changes
|
package/src/host-version.ts
CHANGED
|
File without changes
|
package/src/index.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
34
34
|
import { join } from 'node:path'
|
|
35
35
|
import Schema from '@deepseek-ai/schemastery'
|
|
36
|
-
import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
36
|
+
import { ROUTER_API_BASE, type RouterPeriod, clientIdentityOf } from './shared.ts'
|
|
37
37
|
import { Router } from './router/index.ts'
|
|
38
38
|
import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
|
|
39
39
|
import { KeysStore } from './keys.ts'
|
|
@@ -162,6 +162,11 @@ export function apply(rawContext: unknown): void {
|
|
|
162
162
|
get: (key: string) => unknown
|
|
163
163
|
on: (name: string, listener: (...args: unknown[]) => void) => unknown
|
|
164
164
|
provide: (name: string, value: unknown) => unknown
|
|
165
|
+
/**
|
|
166
|
+
* 广播宿主事件。组合增删改后必须用它发 `llm/adapters-updated`,
|
|
167
|
+
* 否则模型选择器一直拿着旧快照(见 `Router` 的 `onCombosChanged` 注释)。
|
|
168
|
+
*/
|
|
169
|
+
emit: (name: string, ...args: unknown[]) => unknown
|
|
165
170
|
webServer: WebServer
|
|
166
171
|
llm?: Llm
|
|
167
172
|
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
@@ -193,7 +198,13 @@ export function apply(rawContext: unknown): void {
|
|
|
193
198
|
}
|
|
194
199
|
const store = new SupplierConfigStore(stateFile)
|
|
195
200
|
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
196
|
-
|
|
201
|
+
// 组合增删改后广播 `llm/adapters-updated`:模型选择器的目录是客户端缓存的,
|
|
202
|
+
// 只在这个(宿主已转发给客户端的)事件上失效重载。不发它就出现「面板里有、
|
|
203
|
+
// 选择器里找不到/切不过去」(2026-10-14 实测)。
|
|
204
|
+
// 该事件在宿主侧是纯通知(payload-free),监听方都是幂等的重读,重复发安全。
|
|
205
|
+
const router = new Router(stateFile, store, log, {
|
|
206
|
+
onCombosChanged: () => { ctx.emit('llm/adapters-updated') },
|
|
207
|
+
})
|
|
197
208
|
|
|
198
209
|
const keys = new KeysStore(stateFile)
|
|
199
210
|
|
|
@@ -352,9 +363,9 @@ export function apply(rawContext: unknown): void {
|
|
|
352
363
|
writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
|
|
353
364
|
return
|
|
354
365
|
}
|
|
355
|
-
let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown }
|
|
366
|
+
let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown; user?: unknown; prompt_cache_key?: unknown }
|
|
356
367
|
try {
|
|
357
|
-
peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown }
|
|
368
|
+
peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown; user?: unknown; prompt_cache_key?: unknown }
|
|
358
369
|
} catch {
|
|
359
370
|
writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
|
|
360
371
|
return
|
|
@@ -362,9 +373,12 @@ export function apply(rawContext: unknown): void {
|
|
|
362
373
|
const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
|
|
363
374
|
? peek.reasoning_effort
|
|
364
375
|
: 'auto'
|
|
365
|
-
//
|
|
376
|
+
// 调用方身份:宿主会话内部头(adapter 从 GenerateOptions.sessionId 转来)
|
|
377
|
+
// 优先;外部客户端没有内部头,用 user/prompt_cache_key/UA 兜底归因——
|
|
378
|
+
// 否则 usage 记录全是匿名,反查不出是哪个客户端在打。
|
|
366
379
|
const sessionRaw = req.headers['x-dsh-router-session']
|
|
367
|
-
const session = typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined
|
|
380
|
+
const session = (typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined)
|
|
381
|
+
?? clientIdentityOf(peek, req.headers['user-agent'])
|
|
368
382
|
await router.chatCompletions(
|
|
369
383
|
{
|
|
370
384
|
rawBody: body,
|
package/src/keys.ts
CHANGED
|
File without changes
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -156,6 +156,60 @@ test('出口:空的 finish_reason 不许冲掉后面那个真终止原因', as
|
|
|
156
156
|
assert.deepEqual(reason, { kind: 'tool-calls' })
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
+
test('出口:上游报 stop 却发了完整工具调用时,必须以 tool-calls 收尾', async () => {
|
|
160
|
+
// 真实事故(2026-10-15,traework 上游 + Delta 客户端):带 tools 的请求上,
|
|
161
|
+
// 上游正常发出完整的 tool_calls(参数逐片拼全),收尾帧却写
|
|
162
|
+
// `finish_reason: "stop"` —— 与 OpenAI 契约不符(有工具调用应为 tool_calls)。
|
|
163
|
+
//
|
|
164
|
+
// 修复前 mapFinishReason('stop') 直接给 kind='stop',而「无内容」兜底又因
|
|
165
|
+
// 为有内容不触发,于是已攒好的工具块被静默丢弃;调用方据此认为「本轮结束、
|
|
166
|
+
// 没有工具要执行」,表现为「模型能对话但工具永远调不动」(Delta 侧即
|
|
167
|
+
// 「test 能访问但 tool 调用不了」)。
|
|
168
|
+
const reason = await finishOf(
|
|
169
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
|
|
170
|
+
frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
|
|
171
|
+
'[DONE]',
|
|
172
|
+
)
|
|
173
|
+
assert.deepEqual(reason, { kind: 'tool-calls' })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('出口:stop + 工具调用时,工具块本身也要照常交出(不能只改收尾)', async () => {
|
|
177
|
+
const calls = await toolCallsOf(
|
|
178
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
|
|
179
|
+
frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
|
|
180
|
+
'[DONE]',
|
|
181
|
+
)
|
|
182
|
+
assert.deepEqual(calls, [{ type: 'tool-call', id: 'call_1', name: 'bash', arguments: '{"command":"ls"}' }])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test('出口:stop 且没有工具调用时,仍然收尾为 stop(不能误报 tool-calls)', async () => {
|
|
186
|
+
// 反向判据:修复不能把普通对话也标成 tool-calls,否则调用方会空等工具结果。
|
|
187
|
+
const reason = await finishOf(frame({ choices: [{ delta: { content: 'hello' }, finish_reason: 'stop' }] }), '[DONE]')
|
|
188
|
+
assert.deepEqual(reason, { kind: 'stop' })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('出口:length 截断即使带了工具调用也不能改成 tool-calls', async () => {
|
|
192
|
+
// `length` 必须原样透传:dsh-llm 的 assembled() 靠 `finish.kind === 'max-tokens'`
|
|
193
|
+
// 丢弃被截断的工具调用,改成 tool-calls 等于把半截调用放出去执行。
|
|
194
|
+
const reason = await finishOf(
|
|
195
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] } }] }),
|
|
196
|
+
frame({ choices: [{ delta: {}, finish_reason: 'length' }] }),
|
|
197
|
+
'[DONE]',
|
|
198
|
+
)
|
|
199
|
+
assert.deepEqual(reason, { kind: 'max-tokens' })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('出口:stop + 参数残缺的工具调用时,仍判 error(半成品不许当有效调用)', async () => {
|
|
203
|
+
// 与上一条互补:`stop` 只在工具块**完整**时才提升为 tool-calls。
|
|
204
|
+
// 参数残缺时既有逻辑已把 pendingFinish 改成 error,不能被这条修复绕过去。
|
|
205
|
+
const reason = await finishOf(
|
|
206
|
+
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"command": "cd' } }] } }] }),
|
|
207
|
+
frame({ choices: [{ delta: {}, finish_reason: 'stop' }] }),
|
|
208
|
+
'[DONE]',
|
|
209
|
+
)
|
|
210
|
+
assert.equal(reason?.kind, 'error')
|
|
211
|
+
})
|
|
212
|
+
|
|
159
213
|
test('出口:未知终止原因必须带非空 code(agent-loop 会拿它重建 LlmError)', async () => {
|
|
160
214
|
const reason = await finishOf(frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'content_filter' }] }), '[DONE]')
|
|
161
215
|
assert.equal(reason?.kind, 'error')
|
package/src/llm/adapter.ts
CHANGED
|
@@ -444,13 +444,35 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
|
|
|
444
444
|
const usage = toTokenUsage(accumulated)
|
|
445
445
|
if (usage !== null) yield { type: 'usage', usage }
|
|
446
446
|
}
|
|
447
|
+
// 上游报 `stop` **却发了工具调用**时,必须以 tool-calls 收尾。
|
|
448
|
+
//
|
|
449
|
+
// 为什么(2026-10-15 实测):traework 上游在带 tools 的请求上正常发出
|
|
450
|
+
// 完整的 tool_calls(参数逐片拼全),收尾帧却写 `finish_reason: "stop"`
|
|
451
|
+
// ——与 OpenAI 契约不符(有工具调用应为 `tool_calls`)。而 mapFinishReason
|
|
452
|
+
// 只看 reason 字面量,于是 kind='stop',下面那个 `order.length === 0` 的
|
|
453
|
+
// 兜底又因为「有内容」不触发,最终以 stop 收尾 → **已攒好的工具块被静默
|
|
454
|
+
// 丢弃**,调用方(Delta 等)据此认为「本轮结束、没有工具要执行」,表现为
|
|
455
|
+
// 「模型能对话但工具永远调不动」。
|
|
456
|
+
//
|
|
457
|
+
// 判据用 `complete` 而不是 `order`:只有**参数拼全**的工具块才配提升为
|
|
458
|
+
// tool-calls。注意在当前逻辑下两者等价——参数残缺时上面的 `pendingFinish`
|
|
459
|
+
// 已经把它改成 error,`reason.kind` 就不是 'stop',根本走不到这里;
|
|
460
|
+
// 仍写 `complete` 是因为语义直接(「完整的工具调用」),且不依赖那个
|
|
461
|
+
// 副作用:哪天 error 分支挪走或放宽,这里不会跟着把半成品放行。
|
|
462
|
+
//
|
|
463
|
+
// 只改 `stop`:`length`(截断)必须原样透传 —— dsh-llm 的 assembled()
|
|
464
|
+
// 正是靠 `finish.kind === 'max-tokens'` 去丢弃被截断的工具调用,
|
|
465
|
+
// 改掉它就等于把半截调用放出去执行。
|
|
466
|
+
const hasCompleteToolCall = complete.some((block) => block.kind === 'tool-call')
|
|
447
467
|
const reason = pendingFinish ?? { kind: 'stop' }
|
|
468
|
+
const resolved =
|
|
469
|
+
reason.kind === 'stop' && hasCompleteToolCall ? { kind: 'tool-calls' } : reason
|
|
448
470
|
yield {
|
|
449
471
|
type: 'finish',
|
|
450
472
|
reason:
|
|
451
|
-
|
|
473
|
+
resolved.kind === 'stop' && order.length === 0
|
|
452
474
|
? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
|
|
453
|
-
: (
|
|
475
|
+
: (resolved as never),
|
|
454
476
|
}
|
|
455
477
|
return
|
|
456
478
|
}
|
|
@@ -517,6 +517,80 @@ test('状态表:各 AccountState 的处置符合预期', () => {
|
|
|
517
517
|
assert.equal(p.pick(list, [], 'm1'), undefined, '四个号都不可服务')
|
|
518
518
|
})
|
|
519
519
|
|
|
520
|
+
// ---- 插件报的额度/凭证状态直接进选号 ----
|
|
521
|
+
// 分工:插件报「现在怎么了」,核心决定「怎么办」。积分用完(quota)/凭证失效
|
|
522
|
+
// (session_dead)要靠充值/重登才能恢复,选它必是白撞一次——而且 traework 的
|
|
523
|
+
// 积分耗尽失败发生在流已提交之后(HTTP 200 + 流内 4008),这次请求救不回来。
|
|
524
|
+
// 所以必须在**选号阶段**就排除,不能等失败后记账。
|
|
525
|
+
|
|
526
|
+
test('报 quota 的号不进选号(额度用完,选它必白撞)', () => {
|
|
527
|
+
const p = pool()
|
|
528
|
+
const list: SupplierAccountNow[] = [
|
|
529
|
+
{ uid: 'broke', credits: 0, state: 'quota' },
|
|
530
|
+
{ uid: 'rich', credits: 100, state: 'ok' },
|
|
531
|
+
]
|
|
532
|
+
assert.equal(p.pick(list, ['broke', 'rich'], 'm1'), 'rich', '池顺序在前也没用,用不了的号跳过')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
test('报 session_dead 的号不进选号(凭证失效,要重登)', () => {
|
|
536
|
+
const p = pool()
|
|
537
|
+
const list: SupplierAccountNow[] = [
|
|
538
|
+
{ uid: 'dead', credits: 100, state: 'session_dead' },
|
|
539
|
+
{ uid: 'alive', credits: 1, state: 'ok' },
|
|
540
|
+
]
|
|
541
|
+
assert.equal(p.pick(list, ['dead', 'alive'], 'm1'), 'alive')
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('全池都报 quota → 无号可选,且说明原因不是「都在冷却」', () => {
|
|
545
|
+
const p = pool()
|
|
546
|
+
const list: SupplierAccountNow[] = [
|
|
547
|
+
{ uid: 'a', credits: 0, state: 'quota' },
|
|
548
|
+
{ uid: 'b', credits: 0, state: 'quota' },
|
|
549
|
+
]
|
|
550
|
+
assert.equal(p.pick(list, [], 'm1'), undefined)
|
|
551
|
+
assert.match(p.lastWhy, /额度|凭证/, '原因要能区分「没额度」和「冷却中」')
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
test('报 quota 的号恢复成 ok 后立刻回到候选(充值即自愈,无需重启)', () => {
|
|
555
|
+
const p = pool()
|
|
556
|
+
assert.equal(p.pick([{ uid: 'u', credits: 0, state: 'quota' }], [], 'm1'), undefined)
|
|
557
|
+
assert.equal(p.pick([{ uid: 'u', credits: 50, state: 'ok' }], [], 'm1'), 'u')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
test('积分 0 但状态是 ok 的号照常可选(0 不等于「没额度」)', () => {
|
|
561
|
+
const p = pool()
|
|
562
|
+
// openrouter/nvidia 报的就是 credits: 0 + state: 'ok'(没有积分这个概念)。
|
|
563
|
+
// 核心不能自己拿 credits===0 当「用完了」,否则这些供应商会被整池排除。
|
|
564
|
+
const list: SupplierAccountNow[] = [{ uid: 'api-key', credits: 0, state: 'ok' }]
|
|
565
|
+
assert.equal(p.pick(list, [], 'm1'), 'api-key')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
test('瞬时状态(transport/unknown/unavailable)不排除号:那是采样抖动,不是账号事实', () => {
|
|
569
|
+
const p = pool()
|
|
570
|
+
for (const state of ['transport', 'unknown', 'unavailable'] as const) {
|
|
571
|
+
const list: SupplierAccountNow[] = [{ uid: 'u', credits: 5, state }]
|
|
572
|
+
assert.equal(p.pick(list, [], 'm1'), 'u', `${state} 不该让号退出候选`)
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
test('会话绑定撞上报 quota 的号 → 换号,且 lastWhy 指出是额度问题', () => {
|
|
577
|
+
const p = pool()
|
|
578
|
+
const msgs = [{ role: 'user', content: 'hello' }]
|
|
579
|
+
const okList: SupplierAccountNow[] = [
|
|
580
|
+
{ uid: 'bound', credits: 10, state: 'ok' },
|
|
581
|
+
{ uid: 'other', credits: 10, state: 'ok' },
|
|
582
|
+
]
|
|
583
|
+
assert.equal(p.pick(okList, [], 'm1', msgs), 'bound', '先绑到 bound')
|
|
584
|
+
// 该号积分耗尽后:同一会话必须换到别的号,不能继续钉在死号上
|
|
585
|
+
const brokeList: SupplierAccountNow[] = [
|
|
586
|
+
{ uid: 'bound', credits: 0, state: 'quota' },
|
|
587
|
+
{ uid: 'other', credits: 10, state: 'ok' },
|
|
588
|
+
]
|
|
589
|
+
assert.equal(p.pick(brokeList, [], 'm1', msgs), 'other')
|
|
590
|
+
assert.match(p.lastWhy, /bound/, '要说清为什么没用原绑定')
|
|
591
|
+
assert.match(p.lastWhy, /额度|凭证/, '原因要指向额度/凭证,不是「冷却」')
|
|
592
|
+
})
|
|
593
|
+
|
|
520
594
|
/**
|
|
521
595
|
* 处置对齐 9router:未知/瞬时错误**每次**短冷却(不再攒够 3 次)。
|
|
522
596
|
*/
|
|
@@ -586,3 +660,38 @@ test('【隔离】不同供应商的同名模型,各自独立冷却', () => {
|
|
|
586
660
|
// B 供应商同名模型不受影响
|
|
587
661
|
assert.equal(pb.pick(list, [], 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
|
|
588
662
|
})
|
|
663
|
+
|
|
664
|
+
// ============ 限流冷却对齐上游声明的重置时刻(resetAt) ============
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* 2026-10-15 事故:6004 文案自带「将在 … 重置」(小时级),但退避上限 5min →
|
|
668
|
+
* 到点回来再撞,11 个号轮流被打冷。锁住:带 resetAt 就冷到那个点;
|
|
669
|
+
* 过期/未带的 resetAt 退回指数退避(行为与改动前一致)。
|
|
670
|
+
*/
|
|
671
|
+
test('【resetAt】限流冷却到上游声明的重置时刻,而不是 5min 退避上限', () => {
|
|
672
|
+
const p = pool()
|
|
673
|
+
const list = accs(['a'])
|
|
674
|
+
const resetAt = Date.now() + 3 * 3600_000 // 3 小时后重置
|
|
675
|
+
p.noteFailure('a', 'm1', 'rate_limit', 'codebuddy 6004: 您的使用量已超出频率限制,将在 … 重置', resetAt)
|
|
676
|
+
const row = p.decorate(list, 'm1')[0]
|
|
677
|
+
assert.equal(row?.cooling, true)
|
|
678
|
+
assert.equal(row?.until === undefined ? 0 : new Date(row.until).getTime(), resetAt, '冷到上游重置点')
|
|
679
|
+
assert.equal(p.pick(list, [], 'm1'), undefined, '重置前不该再被选中')
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
test('【resetAt】过期的重置时刻退回指数退避;不带 resetAt 行为不变', () => {
|
|
683
|
+
const p = pool()
|
|
684
|
+
const list = accs(['a'])
|
|
685
|
+
// 上游报的重置时刻已过 → 退避(首档 2s 量级),不是「不冷」也不是冷很久
|
|
686
|
+
p.noteFailure('a', 'm1', 'rate_limit', '429', Date.now() - 1000)
|
|
687
|
+
const row = p.decorate(list, 'm1')[0]
|
|
688
|
+
const until = row?.until === undefined ? 0 : new Date(row.until).getTime()
|
|
689
|
+
assert.ok(until > Date.now(), '仍要冷')
|
|
690
|
+
assert.ok(until - Date.now() <= 60_000, '退回短退避,而不是按过期时刻冷')
|
|
691
|
+
// 不带 resetAt(其它供应商/文案没解析出):同样短退避
|
|
692
|
+
const p2 = pool()
|
|
693
|
+
p2.noteFailure('b', 'm1', 'rate_limit', '429')
|
|
694
|
+
const row2 = p2.decorate(accs(['b']), 'm1')[0]
|
|
695
|
+
const until2 = row2?.until === undefined ? 0 : new Date(row2.until).getTime()
|
|
696
|
+
assert.ok(until2 > Date.now() && until2 - Date.now() <= 60_000)
|
|
697
|
+
})
|
|
@@ -54,7 +54,9 @@ const SESSION_DEAD_COOLDOWN_MS = 30 * MINUTE
|
|
|
54
54
|
*
|
|
55
55
|
* 冷却策略对齐 9router 的 accountFallback / errorConfig:
|
|
56
56
|
* - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
|
|
57
|
-
* 短冷,持续限流越退越久,比固定 1
|
|
57
|
+
* 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。上游自己声明了
|
|
58
|
+
* **重置时刻**(resetAt,如 6004「将在 … 重置」)时冷到该点——频率配额是
|
|
59
|
+
* 小时级的,退避上限(5min)只会到点回来再撞。
|
|
58
60
|
* - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
|
|
59
61
|
* 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
|
|
60
62
|
*
|
|
@@ -252,6 +254,25 @@ export class AccountPool {
|
|
|
252
254
|
return c.until <= now
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
/**
|
|
258
|
+
* 插件报的「现在状态」是否意味着这个号此刻不该被选中。
|
|
259
|
+
*
|
|
260
|
+
* 只认两种**账号级持久事实**(要靠外部动作才能恢复,不是抖动):
|
|
261
|
+
* - `quota` —— 额度/积分用完,充值才能恢复(traework 积分归零时报它)
|
|
262
|
+
* - `session_dead` —— 凭证失效,重新登录才能恢复
|
|
263
|
+
*
|
|
264
|
+
* 其余一律不看:`transport`/`unknown`/`unavailable` 是插件某一次采样的瞬时
|
|
265
|
+
* 观察,拿它排除等于「一次抖动就踢号」,而且没有冷却计时器兜底;`rate_limit`
|
|
266
|
+
* 的指数退避由核心的冷却单元管,比状态采样准。
|
|
267
|
+
*
|
|
268
|
+
* 这是「插件只报现在怎么了、核心决定怎么办」的分工落地:插件报出它观察到
|
|
269
|
+
* 的事实,核心决定此刻不选它。恢复同样自动——充值/重登后插件报回 `ok`,
|
|
270
|
+
* 该号立刻回到候选(没有额外状态要清)。
|
|
271
|
+
*/
|
|
272
|
+
private servableByReport(a: SupplierAccountNow | undefined): boolean {
|
|
273
|
+
return a?.state !== 'quota' && a?.state !== 'session_dead'
|
|
274
|
+
}
|
|
275
|
+
|
|
255
276
|
/**
|
|
256
277
|
* 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
|
|
257
278
|
*
|
|
@@ -267,11 +288,17 @@ export class AccountPool {
|
|
|
267
288
|
* "策略",是无会话身份时的替身。
|
|
268
289
|
* 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
|
|
269
290
|
* 降权该号(缓存无效,可恢复)。
|
|
291
|
+
* 5. 插件报的 `quota`/`session_dead`(额度用完 / 凭证失效)→ **直接不选它**:
|
|
292
|
+
* 这类失败要靠充值/重登才能恢复,选它必是白撞一次(还有的供应商失败在
|
|
293
|
+
* 流已提交之后,这次请求根本救不回来)。见 `servableByReport`。
|
|
270
294
|
*
|
|
271
295
|
* 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
|
|
272
296
|
* (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
|
|
273
297
|
*
|
|
274
|
-
* @param accounts
|
|
298
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)。
|
|
299
|
+
* `state` 会被读:`quota`/`session_dead` 的号不进候选(见 `servableByReport`)。
|
|
300
|
+
* `credits` **不读** —— 不同供应商的 0 含义不同(openrouter/nvidia 的 0 是
|
|
301
|
+
* 「没有积分这个概念」),是否已用完由插件解读成 `state` 后报上来。
|
|
275
302
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
276
303
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
277
304
|
* @param messages 请求体的 messages(算亲和指纹的内容兜底)
|
|
@@ -286,9 +313,11 @@ export class AccountPool {
|
|
|
286
313
|
...poolOrder.filter((uid) => byUid.has(uid)),
|
|
287
314
|
...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
|
|
288
315
|
]
|
|
289
|
-
const healthy = ordered.filter(
|
|
316
|
+
const healthy = ordered.filter(
|
|
317
|
+
(uid) => this.healthy(uid, modelId, now) && this.servableByReport(byUid.get(uid)),
|
|
318
|
+
)
|
|
290
319
|
if (healthy.length === 0) {
|
|
291
|
-
this.lastWhy = '
|
|
320
|
+
this.lastWhy = '全池无健康号(都在冷却,或插件报的额度/凭证已不可用)'
|
|
292
321
|
return undefined
|
|
293
322
|
}
|
|
294
323
|
// 缓存无效降权:该号缓存质量样本长期不达标 → 跳过它(可到期恢复)
|
|
@@ -309,8 +338,13 @@ export class AccountPool {
|
|
|
309
338
|
why.push('该指纹无绑定(会话首次出现)')
|
|
310
339
|
} else {
|
|
311
340
|
if (!healthy.includes(bound)) {
|
|
312
|
-
const
|
|
313
|
-
|
|
341
|
+
const ba = byUid.get(bound)
|
|
342
|
+
if (!this.servableByReport(ba)) {
|
|
343
|
+
why.push(`原绑定 ${bound} 插件报额度/凭证不可用(${ba?.state})`)
|
|
344
|
+
} else {
|
|
345
|
+
const c = this.cooldowns.get(this.key(modelId, bound))
|
|
346
|
+
why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ''}`)
|
|
347
|
+
}
|
|
314
348
|
} else if (!usable.includes(bound)) {
|
|
315
349
|
why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`)
|
|
316
350
|
} else {
|
|
@@ -463,8 +497,13 @@ export class AccountPool {
|
|
|
463
497
|
return `${this.supplierId}${SEP}${modelId}`
|
|
464
498
|
}
|
|
465
499
|
|
|
466
|
-
/**
|
|
467
|
-
|
|
500
|
+
/**
|
|
501
|
+
* 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。
|
|
502
|
+
* `resetAt` = 上游声明的重置时刻(epoch ms):限流退避优先冷到该点——
|
|
503
|
+
* 频率配额是小时级的,5min 上限的指数退避只会到点回来再撞 6004,
|
|
504
|
+
* 把号轮流打冷(2026-10-15 的 11 号全灭事故)。
|
|
505
|
+
*/
|
|
506
|
+
noteFailure(uid: string, modelId: string, state: AccountState, message: string, resetAt?: number): void {
|
|
468
507
|
const rule = RULES[state]
|
|
469
508
|
if (rule.cooldown === 0) return // no_such_model 等:不冷不记
|
|
470
509
|
// session_dead 是连接级冷却(登录态问题,跨该号所有模型),到期自愈——
|
|
@@ -482,7 +521,11 @@ export class AccountPool {
|
|
|
482
521
|
if (rule.cooldown === 'backoff') {
|
|
483
522
|
// 连续被限流 → 等级递进冷却;成功(noteSuccess)会清零等级
|
|
484
523
|
e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL)
|
|
485
|
-
|
|
524
|
+
const backoffUntil = Date.now() + backoffMs(e.backoffLevel)
|
|
525
|
+
// 上游自己报了重置时刻就冷到那个点(且不低于已有的更长冷却);过期/
|
|
526
|
+
// 未报的 resetAt 退回指数退避。
|
|
527
|
+
const until = resetAt !== undefined && resetAt > Date.now() ? resetAt : backoffUntil
|
|
528
|
+
e.until = Math.max(e.until, until)
|
|
486
529
|
return
|
|
487
530
|
}
|
|
488
531
|
if (rule.cooldown === 'transient') {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组合变更必须通知拓扑观察者 —— `onCombosChanged` 的出口判据。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 为什么需要它(2026-10-14 实测的「面板里有,模型选择器里找不到」):
|
|
5
|
+
* 模型选择器(`ui-model-selection`)的模型目录是**客户端缓存**的:每个浏览器
|
|
6
|
+
* 连接只 `load()` 一次,之后只在宿主转发的事件上失效重载
|
|
7
|
+
* (`llm/adapters-updated` / `settings/document-updated` / 凭据事件 / 重连)。
|
|
8
|
+
* 而组合只是本插件 `combos.json` 里的一份状态:
|
|
9
|
+
* - `createCombo` 建了新组合 → 选择器永远看不到它
|
|
10
|
+
* - `updateCombo` 改了名字 → 选择器还拿旧名字,用户切不过去
|
|
11
|
+
* - `removeCombo` 删了组合 → 选择器里还留着,选中必然失败
|
|
12
|
+
* 实测现场:`combos.json` 里是 `buddy-models-flash`(22:19 改的名),
|
|
13
|
+
* 而选择器里显示的是**早已删除的** `NVIDIA-models` —— 整张表都过期了。
|
|
14
|
+
*
|
|
15
|
+
* 判据形态:直接守「提交成功后有没有发出通知」,而不是守客户端有没有刷新 ——
|
|
16
|
+
* 客户端刷新要靠宿主转发,单测里没有宿主。通知是这里唯一能守住的那一环。
|
|
17
|
+
*/
|
|
18
|
+
import { test } from 'node:test'
|
|
19
|
+
import assert from 'node:assert/strict'
|
|
20
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { Router } from './index.ts'
|
|
24
|
+
|
|
25
|
+
/** 造一个空 stateFile 的临时目录(组合落在同目录的 combos.json)。 */
|
|
26
|
+
function fixture(): { dir: string; stateFile: string } {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-router-combo-'))
|
|
28
|
+
return { dir, stateFile: join(dir, 'state.json') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('★ createCombo 成功后通知一次(否则新组合在选择器里永不出现)', () => {
|
|
32
|
+
const { stateFile } = fixture()
|
|
33
|
+
let notified = 0
|
|
34
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
35
|
+
assert.equal(router.createCombo('c1', ['supA,a-model']).ok, true)
|
|
36
|
+
assert.equal(notified, 1, '新建组合后没有通知 ⇒ 模型选择器看不到它')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('★ updateCombo 成功后通知(否则选择器还拿旧名字,切不过去)', () => {
|
|
40
|
+
const { stateFile } = fixture()
|
|
41
|
+
let notified = 0
|
|
42
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
43
|
+
router.createCombo('old-name', ['supA,a-model'])
|
|
44
|
+
notified = 0
|
|
45
|
+
assert.equal(router.updateCombo('old-name', 'new-name', ['supA,a-model']).ok, true)
|
|
46
|
+
assert.equal(notified, 1, '改名后没有通知 ⇒ 选择器里还留着旧名字')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('★ removeCombo 成功后通知(否则选择器里留着已删的组合,选中必失败)', () => {
|
|
50
|
+
const { stateFile } = fixture()
|
|
51
|
+
let notified = 0
|
|
52
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
53
|
+
router.createCombo('gone', ['supA,a-model'])
|
|
54
|
+
notified = 0
|
|
55
|
+
assert.equal(router.removeCombo('gone').ok, true)
|
|
56
|
+
assert.equal(notified, 1, '删除后没有通知 ⇒ 选择器里还留着它')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('被拒绝的变更不通知(校验失败时组合根本没变)', () => {
|
|
60
|
+
const { stateFile } = fixture()
|
|
61
|
+
let notified = 0
|
|
62
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
63
|
+
router.createCombo('keep', ['supA,a-model'])
|
|
64
|
+
notified = 0
|
|
65
|
+
// 空模型列表 / 非法名字 / 不存在的 id:三条都得原地不动
|
|
66
|
+
assert.equal(router.createCombo('bad-chars!', ['supA,a-model']).ok, false)
|
|
67
|
+
assert.equal(router.createCombo('no-models', []).ok, false)
|
|
68
|
+
assert.equal(router.updateCombo('missing-id', 'x', ['supA,a-model']).ok, false)
|
|
69
|
+
assert.equal(router.removeCombo('missing-id').ok, false)
|
|
70
|
+
assert.equal(notified, 0, '失败的变更不该通知(会让客户端做一次无意义的全量重拉)')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('通知出口抛错不能把已提交的变更报成失败', async () => {
|
|
74
|
+
const { stateFile } = fixture()
|
|
75
|
+
const router = new Router(stateFile, undefined, undefined, {
|
|
76
|
+
// 监听方(宿主事件总线)出问题:这是通知不是事务参与者
|
|
77
|
+
onCombosChanged: () => { throw new Error('listener exploded') },
|
|
78
|
+
})
|
|
79
|
+
const result = router.createCombo('survivor', ['supA,a-model'])
|
|
80
|
+
assert.equal(result.ok, true, '通知失败不该让 createCombo 变红 —— 组合其实已经提交')
|
|
81
|
+
assert.equal((await router.combos()).some((c) => c.name === 'survivor'), true, '内存里的组合必须还在')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('没装出口时不抛(单测/独立运行时)', () => {
|
|
85
|
+
const { stateFile } = fixture()
|
|
86
|
+
const router = new Router(stateFile)
|
|
87
|
+
assert.doesNotThrow(() => { router.createCombo('c1', ['supA,a-model']) })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('通知发生在落盘之后 —— 监听方重读时能读到新组合', () => {
|
|
91
|
+
const { stateFile } = fixture()
|
|
92
|
+
let seen: string[] = []
|
|
93
|
+
const router = new Router(stateFile, undefined, undefined, {
|
|
94
|
+
onCombosChanged: () => {
|
|
95
|
+
// 监听方(宿主)会重新读一遍组合去刷客户端,此刻必须已经能读到新的
|
|
96
|
+
const onDisk = JSON.parse(readFileSync(join(stateFile, '..', 'combos.json'), 'utf8')) as {
|
|
97
|
+
combos: Array<{ name: string }>
|
|
98
|
+
}
|
|
99
|
+
seen = onDisk.combos.map((c) => c.name)
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
router.createCombo('persisted', ['supA,a-model'])
|
|
103
|
+
assert.deepEqual(seen, ['persisted'], '通知时组合还没落盘 ⇒ 监听方重读会拿到旧状态')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('重复写同一个组合名不触发第二次落盘语义变化(幂等读取安全)', () => {
|
|
107
|
+
const { stateFile } = fixture()
|
|
108
|
+
let notified = 0
|
|
109
|
+
const router = new Router(stateFile, undefined, undefined, { onCombosChanged: () => { notified += 1 } })
|
|
110
|
+
router.createCombo('c1', ['supA,a-model'])
|
|
111
|
+
// 同 id 的 update 到等价内容仍然是合法提交(不是 no-op 语义),必须通知
|
|
112
|
+
assert.equal(router.updateCombo('c1', 'c1', ['supA,a-model']).ok, true)
|
|
113
|
+
assert.equal(notified, 2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('★ 注入:拿掉通知 ⇒ 上面几条会红(反向自检)', () => {
|
|
117
|
+
const { stateFile } = fixture()
|
|
118
|
+
// 等价于修复前的实现:没有 onCombosChanged
|
|
119
|
+
const router = new Router(stateFile)
|
|
120
|
+
router.createCombo('c1', ['supA,a-model'])
|
|
121
|
+
const combosFile = join(stateFile, '..', 'combos.json')
|
|
122
|
+
// 落盘仍然发生(这条路径没被改坏),但没有任何通知出口可发
|
|
123
|
+
const onDisk = JSON.parse(readFileSync(combosFile, 'utf8')) as { combos: unknown[] }
|
|
124
|
+
assert.equal(onDisk.combos.length, 1, '落盘行为必须保持')
|
|
125
|
+
})
|
|
File without changes
|
|
File without changes
|