@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10
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/CHANGELOG.md +44 -0
- package/README.md +197 -26
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
- package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
- package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
- package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
- package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
- package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
- package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
- package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
- package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
- package/docs/DSH_TO_CODEX_INBOX.md +167 -0
- package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
- package/docs/codex-desktop-attachment-evidence.md +116 -0
- package/harness/audit-tail.mjs +32 -0
- package/harness/codex-approvals.mjs +1078 -0
- package/harness/codex-associations.mjs +297 -0
- package/harness/codex-outbox.mjs +432 -0
- package/harness/dsh-bridge.mjs +2429 -167
- package/harness/fs-lock.mjs +208 -0
- package/harness/model-route-host.mjs +530 -0
- package/harness/model-routing.mjs +500 -0
- package/harness/session-observer.mjs +217 -0
- package/harness/session-target.mjs +291 -2
- package/harness/task-table.mjs +34 -0
- package/mcp/codex-desktop-probe.mjs +407 -0
- package/mcp/codex-inbox.mjs +271 -0
- package/mcp/dsh-mcp.mjs +616 -24
- package/package.json +4 -2
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 动态模型路由(M1):目录快照、候选选择与有界故障切换。
|
|
3
|
+
*
|
|
4
|
+
* 设计约束(来自 Root canonical 计划与 SDK 只读笔记):
|
|
5
|
+
* - 只用公有 API `llm.listProviders/listModels/resolveModelInfo/resolveCallConfig`;只枚举**已注册**配置;
|
|
6
|
+
* 目录可见 ≠ 凭据/额度/健康已验证(本模块从不把 catalog 当健康结论)。
|
|
7
|
+
* - provider+model 是**唯一身份(pair)**:跨 provider 同名模型绝不合并。
|
|
8
|
+
* - 目录失败**按 provider 隔离**;缓存有界且可失效;**永不永久缓存 null**。
|
|
9
|
+
* - 元数据没有质量/价格/速度评分;`toolUpdate` 是历史投影规则,**不当作"支持工具"布尔**。
|
|
10
|
+
* - 先满足真实模态/上下文/effort 与用户显式选择,再按 DeepSeek 偏好与可解释名称提示排序。
|
|
11
|
+
* - 失败分类只用**稳定 code/status**;切换有界(默认最多 3 个不同候选、单路与总时限)。
|
|
12
|
+
*
|
|
13
|
+
* 本模块**不**:调用 selectModel/saveSelection、写 readonly options、改全局 preset、发起任何网络请求。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 目录快照默认 TTL(毫秒)。拓扑变更事件会主动失效。 */
|
|
17
|
+
export const CATALOG_TTL_MS = 30_000
|
|
18
|
+
/** 目录失败(某 provider)的短冷却:**不永久缓存 null**,到期后重新尝试。 */
|
|
19
|
+
export const CATALOG_FAILURE_TTL_MS = 5_000
|
|
20
|
+
/** 单次快照最多枚举的 provider / 每 provider 模型数(有界)。 */
|
|
21
|
+
export const MAX_PROVIDERS = 32
|
|
22
|
+
export const MAX_MODELS_PER_PROVIDER = 200
|
|
23
|
+
|
|
24
|
+
export const ROUTE_MODES = ['auto', 'fast', 'pro', 'inherit', 'exact']
|
|
25
|
+
|
|
26
|
+
/** 稳定失败码分类(不读 message 文本)。 */
|
|
27
|
+
export const SWITCHABLE_CODES = new Set([
|
|
28
|
+
'AUTH', 'MISSING_CREDENTIAL', 'ACCOUNT_QUOTA', 'QUOTA', 'UNKNOWN_MODEL', 'MODEL_NOT_FOUND', 'NO_ADAPTER',
|
|
29
|
+
])
|
|
30
|
+
export const RETRYABLE_CODES = new Set(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT', 'EMPTY_RESPONSE'])
|
|
31
|
+
/** 明确**不**靠切模型绕过:用户取消、拒答/安全、审批、工具错误、请求本身非法、未知插件 bug。 */
|
|
32
|
+
export const NEVER_SWITCH_CODES = new Set([
|
|
33
|
+
'ABORTED', 'USER_ABORT', 'CANCELLED', 'SAFETY', 'CONTENT_FILTER', 'REFUSAL',
|
|
34
|
+
'TOOL_ERROR', 'TOOL_FAILED', 'APPROVAL_DENIED', 'APPROVAL_REJECTED', 'INVALID_REQUEST', 'UNSUPPORTED_REASONING_EFFORT',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const pairKey = (provider, model) => String(provider) + '\u0000' + String(model)
|
|
38
|
+
export const pairOf = (provider, model) => ({ provider, model, key: pairKey(provider, model) })
|
|
39
|
+
|
|
40
|
+
/** 有界等待/超时(目录发现、retry-after 都用它;绝不无限等)。 */
|
|
41
|
+
export const withDeadline = (promise, ms, onTimeout) => new Promise((resolve, reject) => {
|
|
42
|
+
const timer = setTimeout(() => {
|
|
43
|
+
if (typeof onTimeout === 'function') { try { onTimeout() } catch (e) { /* ignore */ } }
|
|
44
|
+
reject(Object.assign(new Error('deadline exceeded after ' + ms + 'ms'), { code: 'DEADLINE_EXCEEDED' }))
|
|
45
|
+
}, Math.max(1, ms))
|
|
46
|
+
Promise.resolve(promise).then(
|
|
47
|
+
(value) => { clearTimeout(timer); resolve(value) },
|
|
48
|
+
(error) => { clearTimeout(timer); reject(error) },
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** 有界延时(retry-after 用):到点或被 signal/aborted 立刻返回,绝不无限等。 */
|
|
53
|
+
export const boundedDelay = (ms, options = {}) => new Promise((resolve) => {
|
|
54
|
+
const cap = Number.isFinite(options.maxMs) ? Math.max(0, Math.min(ms, options.maxMs)) : Math.max(0, ms)
|
|
55
|
+
let done = false
|
|
56
|
+
const finish = () => { if (done) return; done = true; clearTimeout(timer); if (typeof options.onWake === 'function') options.onWake(); resolve() }
|
|
57
|
+
const timer = setTimeout(finish, cap)
|
|
58
|
+
const signal = options.signal
|
|
59
|
+
if (signal !== undefined && signal !== null && typeof signal.addEventListener === 'function') {
|
|
60
|
+
if (signal.aborted === true) { finish(); return }
|
|
61
|
+
signal.addEventListener('abort', finish, { once: true })
|
|
62
|
+
}
|
|
63
|
+
if (typeof options.isCancelled === 'function' && options.isCancelled() === true) finish()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const modelIdOf = (entry) => {
|
|
67
|
+
if (typeof entry === 'string') return entry
|
|
68
|
+
if (entry === null || typeof entry !== 'object') return ''
|
|
69
|
+
return typeof entry.id === 'string' && entry.id !== '' ? entry.id : (typeof entry.name === 'string' ? entry.name : '')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const modalitiesOf = (info) => {
|
|
73
|
+
// 未知 → 返回 undefined(诚实标未知,不伪造支持)
|
|
74
|
+
const raw = info !== null && typeof info === 'object' ? (info.inputModalities !== undefined ? info.inputModalities : (info.model !== undefined ? info.model.inputModalities : undefined)) : undefined
|
|
75
|
+
if (!Array.isArray(raw)) return undefined
|
|
76
|
+
return raw.filter((m) => typeof m === 'string')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 单 provider 目录发现的有界超时(防止一个挂死的 adapter 拖住所有候选)。 */
|
|
80
|
+
export const PROVIDER_DISCOVERY_TIMEOUT_MS = 3000
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 从 ResolvedModelInfo 提取可解释能力(缺字段 = 未知,绝不当作"支持")。
|
|
84
|
+
* 注意:官方 `reasoning.efforts` 是**对象数组** `[{id,name}, ...]`(SDK 实测),
|
|
85
|
+
* 也兼容字符串数组;只滤 string 会把支持的 effort 全判成空数组(第一版验收反例)。
|
|
86
|
+
*/
|
|
87
|
+
export const capabilityOf = (info) => {
|
|
88
|
+
const out = { known: false }
|
|
89
|
+
if (info === null || typeof info !== 'object') return out
|
|
90
|
+
const context = info.context !== null && typeof info.context === 'object' ? info.context : undefined
|
|
91
|
+
if (context !== undefined && Number.isFinite(context.contextWindow)) { out.contextWindow = context.contextWindow; out.known = true }
|
|
92
|
+
if (Number.isFinite(info.defaultMaxTokens)) { out.defaultMaxTokens = info.defaultMaxTokens; out.known = true }
|
|
93
|
+
const reasoning = info.reasoning !== null && typeof info.reasoning === 'object' ? info.reasoning : undefined
|
|
94
|
+
if (reasoning !== undefined) {
|
|
95
|
+
if (Array.isArray(reasoning.efforts)) {
|
|
96
|
+
const ids = []
|
|
97
|
+
for (const entry of reasoning.efforts) {
|
|
98
|
+
if (typeof entry === 'string') { ids.push(entry); continue }
|
|
99
|
+
if (entry !== null && typeof entry === 'object' && typeof entry.id === 'string') ids.push(entry.id)
|
|
100
|
+
}
|
|
101
|
+
out.efforts = ids
|
|
102
|
+
}
|
|
103
|
+
const defaultEffort = reasoning.defaultEffort
|
|
104
|
+
if (typeof defaultEffort === 'string') out.defaultEffort = defaultEffort
|
|
105
|
+
else if (defaultEffort !== null && typeof defaultEffort === 'object' && typeof defaultEffort.id === 'string') out.defaultEffort = defaultEffort.id
|
|
106
|
+
out.known = true
|
|
107
|
+
}
|
|
108
|
+
const modalities = modalitiesOf(info)
|
|
109
|
+
if (modalities !== undefined) { out.inputModalities = modalities; out.known = true }
|
|
110
|
+
// toolUpdate/systemPromptUpdate 是**历史投影规则**,只作为附加事实保留,绝不当"支持工具"布尔。
|
|
111
|
+
const toolUpdate = info.toolUpdate !== undefined ? info.toolUpdate : (info.model !== undefined ? info.model.toolUpdate : undefined)
|
|
112
|
+
if (typeof toolUpdate === 'string' || (toolUpdate !== null && typeof toolUpdate === 'object')) out.toolUpdateRule = toolUpdate
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 已证不可用的解析失败码(必须**排除**候选,不能降级成"未知能力"再送去准备期失败)。 */
|
|
117
|
+
export const UNRESOLVABLE_CAPABILITY_CODES = new Set(['UNKNOWN_MODEL', 'MODEL_NOT_FOUND', 'INVALID_CONFIG', 'NO_ADAPTER', 'UNSUPPORTED_REASONING_EFFORT'])
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 目录快照:provider 失败隔离、有界缓存、可主动失效、失败**不永久**缓存。
|
|
121
|
+
* `llm` 为官方 Llm 服务(listProviders/listModels/resolveModelInfo)。
|
|
122
|
+
*/
|
|
123
|
+
export const createModelCatalog = (options = {}) => {
|
|
124
|
+
const llm = options.llm
|
|
125
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
126
|
+
const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : CATALOG_TTL_MS
|
|
127
|
+
const failureTtlMs = Number.isFinite(options.failureTtlMs) ? options.failureTtlMs : CATALOG_FAILURE_TTL_MS
|
|
128
|
+
const providerTimeoutMs = Number.isFinite(options.providerTimeoutMs) ? options.providerTimeoutMs : PROVIDER_DISCOVERY_TIMEOUT_MS
|
|
129
|
+
let cache = null // { at, pairs, failures }
|
|
130
|
+
const providerFailures = new Map() // provider → { at, reason }
|
|
131
|
+
const infoCache = new Map() // pairKey → { at, info }
|
|
132
|
+
|
|
133
|
+
const invalidate = () => { cache = null; infoCache.clear() }
|
|
134
|
+
|
|
135
|
+
const snapshot = async ({ force = false } = {}) => {
|
|
136
|
+
const at = now()
|
|
137
|
+
if (!force && cache !== null && at - cache.at < ttlMs) return { ...cache, stale: false, servedFromCache: true }
|
|
138
|
+
if (llm === undefined || llm === null || typeof llm.listProviders !== 'function') {
|
|
139
|
+
return { at, pairs: [], failures: [{ provider: '*', reason: 'llm-unavailable', retryable: true }], stale: false, servedFromCache: false, unavailable: true }
|
|
140
|
+
}
|
|
141
|
+
const pairs = []
|
|
142
|
+
const failures = []
|
|
143
|
+
let providers = []
|
|
144
|
+
try {
|
|
145
|
+
providers = await llm.listProviders()
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// 整个目录不可用:不缓存成"空目录"结论,只记一次失败(下次仍尝试)。
|
|
148
|
+
return { at, pairs: [], failures: [{ provider: '*', reason: 'list-providers-failed', retryable: true }], stale: false, servedFromCache: false, unavailable: true }
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(providers)) providers = []
|
|
151
|
+
for (const raw of providers.slice(0, MAX_PROVIDERS)) {
|
|
152
|
+
const provider = typeof raw === 'string' ? raw : (raw !== null && typeof raw === 'object' && typeof raw.id === 'string' ? raw.id : '')
|
|
153
|
+
if (provider === '') continue
|
|
154
|
+
const failed = providerFailures.get(provider)
|
|
155
|
+
if (failed !== undefined && at - failed.at < failureTtlMs && !force) {
|
|
156
|
+
failures.push({ provider, reason: failed.reason, retryable: true, cachedFailure: true })
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
// 有界目录发现:单个 provider 挂死不能拖住其它候选(第一版验收反例:首 provider 不返回 → 整次 snapshot 不返回)。
|
|
161
|
+
const models = await withDeadline(Promise.resolve(llm.listModels(provider)), providerTimeoutMs)
|
|
162
|
+
if (!Array.isArray(models)) throw new Error('listModels did not return an array')
|
|
163
|
+
const seen = new Set()
|
|
164
|
+
let added = 0
|
|
165
|
+
for (const entry of models.slice(0, MAX_MODELS_PER_PROVIDER)) {
|
|
166
|
+
const model = modelIdOf(entry)
|
|
167
|
+
if (model === '' || seen.has(model)) continue // 同 provider 重复 id 只留一条
|
|
168
|
+
seen.add(model)
|
|
169
|
+
pairs.push({
|
|
170
|
+
provider, model, key: pairKey(provider, model),
|
|
171
|
+
name: typeof entry === 'object' && entry !== null && typeof entry.name === 'string' ? entry.name : model,
|
|
172
|
+
...(typeof entry === 'object' && entry !== null && typeof entry.description === 'string' ? { description: entry.description } : {}),
|
|
173
|
+
...(typeof entry === 'object' && entry !== null && Array.isArray(entry.inputModalities) ? { inputModalities: entry.inputModalities.filter((m) => typeof m === 'string') } : {}),
|
|
174
|
+
order: pairs.length,
|
|
175
|
+
})
|
|
176
|
+
added++
|
|
177
|
+
}
|
|
178
|
+
providerFailures.delete(provider)
|
|
179
|
+
if (added === 0) failures.push({ provider, reason: 'no-models', retryable: false })
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// **失败隔离**:只影响该 provider,其它 provider 的候选照常可用。
|
|
182
|
+
const reason = typeof e === 'object' && e !== null && typeof e.code === 'string' ? e.code : 'list-models-failed'
|
|
183
|
+
providerFailures.set(provider, { at, reason })
|
|
184
|
+
failures.push({ provider, reason, retryable: true })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
cache = { at, pairs, failures }
|
|
188
|
+
return { at, pairs, failures, stale: false, servedFromCache: false }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 解析单 pair 能力(有界缓存;失败**不**缓存成永久未知)。 */
|
|
192
|
+
const resolveInfo = async (provider, model) => {
|
|
193
|
+
const key = pairKey(provider, model)
|
|
194
|
+
const at = now()
|
|
195
|
+
const hit = infoCache.get(key)
|
|
196
|
+
if (hit !== undefined && at - hit.at < ttlMs) return { ok: true, info: hit.info, capability: hit.capability }
|
|
197
|
+
if (llm === undefined || llm === null || typeof llm.resolveModelInfo !== 'function') return { ok: false, reason: 'llm-unavailable' }
|
|
198
|
+
try {
|
|
199
|
+
const info = await llm.resolveModelInfo(provider, model)
|
|
200
|
+
const capability = capabilityOf(info)
|
|
201
|
+
infoCache.set(key, { at, info, capability })
|
|
202
|
+
// 有界:最多 512 条
|
|
203
|
+
if (infoCache.size > 512) infoCache.delete(infoCache.keys().next().value)
|
|
204
|
+
return { ok: true, info, capability }
|
|
205
|
+
} catch (e) {
|
|
206
|
+
const reason = typeof e === 'object' && e !== null && typeof e.code === 'string' ? e.code : 'resolve-model-info-failed'
|
|
207
|
+
return { ok: false, reason }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { snapshot, resolveInfo, invalidate, _cache: () => cache }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 名称提示(可解释,不当作质量/价格承诺):fast/pro 在模型名上的弱信号。 */
|
|
215
|
+
const nameHints = (model) => {
|
|
216
|
+
const id = String(model).toLowerCase()
|
|
217
|
+
const hints = []
|
|
218
|
+
if (/flash|lite|fast|mini|light|small/.test(id)) hints.push('fast')
|
|
219
|
+
if (/pro|max|ultra|large|reason/.test(id)) hints.push('pro')
|
|
220
|
+
return hints
|
|
221
|
+
}
|
|
222
|
+
const isDeepSeek = (provider) => /deepseek/i.test(String(provider))
|
|
223
|
+
/** DeepSeek 偏好同时看 provider 与**模型 id/name**(自定义 provider 下的 DeepSeek 模型也要优先)。 */
|
|
224
|
+
const isDeepSeekPair = (pair) => /deepseek/i.test(String(pair.provider)) || /deepseek/i.test(String(pair.model)) || /deepseek/i.test(String(pair.name === undefined ? '' : pair.name))
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 初次选择:先满足用户显式约束与真实能力,再按 DeepSeek 偏好 + 任务类型 + 名称提示排序。
|
|
228
|
+
* 返回稳定形态,绝不静默选错:未知/歧义 → 明确 reason + candidates。
|
|
229
|
+
* `needs.excludeKeys` / `excludeKey`:已选中的 pair 不重复进入备选池。
|
|
230
|
+
*/
|
|
231
|
+
export const selectRoute = async ({ catalog, request = {}, resolveInfo }) => {
|
|
232
|
+
if (catalog === undefined || catalog === null) return { ok: false, reason: 'catalog-unavailable' }
|
|
233
|
+
const snap = await catalog.snapshot()
|
|
234
|
+
const mode = ROUTE_MODES.includes(request.mode) ? request.mode : 'auto'
|
|
235
|
+
const failures = snap.failures || []
|
|
236
|
+
const needs = request.needs !== null && typeof request.needs === 'object' ? request.needs : {}
|
|
237
|
+
|
|
238
|
+
/** 候选排序(auto/fast/pro 与 exact 的备选池共用同一套能力过滤与偏好)。 */
|
|
239
|
+
const rank = async ({ excludeKey } = {}) => {
|
|
240
|
+
const scored = []
|
|
241
|
+
for (const pair of snap.pairs) {
|
|
242
|
+
if (excludeKey !== undefined && pair.key === excludeKey) continue
|
|
243
|
+
const info = resolveInfo !== undefined ? await resolveInfo(pair.provider, pair.model) : { ok: false, reason: 'no-resolver' }
|
|
244
|
+
// 已证不可用(结构化解析失败码):**排除**候选,绝不降级成"未知能力"再送进准备期失败。
|
|
245
|
+
if (info.ok !== true && typeof info.reason === 'string' && UNRESOLVABLE_CAPABILITY_CODES.has(info.reason)) {
|
|
246
|
+
scored.push({ pair, rejected: 'capability-unresolvable:' + info.reason, reasons: ['capability-unresolvable'] })
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
const cap = info.ok === true ? info.capability : { known: false, unresolvedReason: info.reason }
|
|
250
|
+
const reasons = []
|
|
251
|
+
let rejected = null
|
|
252
|
+
if (Array.isArray(needs.inputModalities) && needs.inputModalities.length > 0) {
|
|
253
|
+
if (Array.isArray(cap.inputModalities)) {
|
|
254
|
+
const missing = needs.inputModalities.filter((m) => !cap.inputModalities.includes(m))
|
|
255
|
+
if (missing.length > 0) rejected = 'modality-unsupported'
|
|
256
|
+
} else if (needs.requireKnownCapabilities === true) rejected = 'modality-unknown'
|
|
257
|
+
}
|
|
258
|
+
if (rejected === null && Number.isFinite(needs.contextWindow) && Number.isFinite(cap.contextWindow) && cap.contextWindow < needs.contextWindow) rejected = 'context-too-small'
|
|
259
|
+
if (rejected === null && typeof needs.effort === 'string' && Array.isArray(cap.efforts) && !cap.efforts.includes(needs.effort)) rejected = 'effort-unsupported'
|
|
260
|
+
if (rejected !== null) { scored.push({ pair, rejected, reasons: [rejected] }); continue }
|
|
261
|
+
let score = 0
|
|
262
|
+
if (isDeepSeekPair(pair)) { score += 100; reasons.push('deepseek-preferred') }
|
|
263
|
+
const hints = nameHints(pair.model)
|
|
264
|
+
if (mode === 'fast' && hints.includes('fast')) { score += 20; reasons.push('fast-hint') }
|
|
265
|
+
if (mode === 'pro' && hints.includes('pro')) { score += 20; reasons.push('pro-hint') }
|
|
266
|
+
if (request.taskKind === 'review' && /pro|max|reason/.test(String(pair.model).toLowerCase())) { score += 5; reasons.push('review-prefers-stronger-name') }
|
|
267
|
+
if (cap.known !== true) reasons.push('capability-unknown')
|
|
268
|
+
scored.push({ pair, score, reasons, order: pair.order, capability: cap })
|
|
269
|
+
}
|
|
270
|
+
const usable = scored.filter((s) => s.rejected === undefined).sort((a, b) => (b.score - a.score) || (a.order - b.order))
|
|
271
|
+
return { scored, usable }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** exact:也要给出**备选池**(显式 modelFallback:true 才有池可用;默认空池=固定)。 */
|
|
275
|
+
const poolFor = async (pair) => {
|
|
276
|
+
if (request.allowFallback === false) return []
|
|
277
|
+
const ranked = await rank({ excludeKey: pair.key })
|
|
278
|
+
return ranked.usable.slice(0, 3).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score, key: s.pair.key }))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (mode === 'inherit') {
|
|
282
|
+
// default(继承):不覆盖当前选择;若调用方**显式**允许 fallback,则预先算好"失败时可用"的池
|
|
283
|
+
// (首 attempt 用机器实际配置,失败后才有备选可用)。
|
|
284
|
+
if (request.allowFallback !== true) return { ok: true, mode, inherit: true, reason: 'explicit-inherit' }
|
|
285
|
+
const ranked = await rank()
|
|
286
|
+
return {
|
|
287
|
+
ok: true, mode, inherit: true, reason: 'inherit-with-fallback-pool', initial: null,
|
|
288
|
+
alternatives: ranked.usable.slice(0, 3).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score, key: s.pair.key })),
|
|
289
|
+
failures,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// 精确指定:provider+model 必须都在目录内(未给 provider 且同名歧义 → 拒绝并给候选)
|
|
294
|
+
if (mode === 'exact' || (typeof request.model === 'string' && request.model !== '')) {
|
|
295
|
+
const model = typeof request.model === 'string' ? request.model : ''
|
|
296
|
+
if (model === '') return { ok: false, reason: 'model-required' }
|
|
297
|
+
if (typeof request.provider === 'string' && request.provider !== '') {
|
|
298
|
+
const hit = snap.pairs.find((p) => p.provider === request.provider && p.model === model)
|
|
299
|
+
if (hit === undefined) {
|
|
300
|
+
const sameModel = snap.pairs.filter((p) => p.model === model)
|
|
301
|
+
return { ok: false, reason: 'exact-pair-not-in-catalog', provider: request.provider, model, candidates: sameModel }
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, mode: 'exact', pair: hit, reason: 'exact-pair', alternatives: await poolFor(hit), failures }
|
|
304
|
+
}
|
|
305
|
+
const matches = snap.pairs.filter((p) => p.model === model)
|
|
306
|
+
if (matches.length === 0) return { ok: false, reason: 'model-not-in-catalog', model, candidates: [] }
|
|
307
|
+
if (matches.length > 1) {
|
|
308
|
+
// 同名跨 provider:**不静默合并**,要求调用方给出 provider
|
|
309
|
+
return { ok: false, reason: 'model-ambiguous-across-providers', model, candidates: matches }
|
|
310
|
+
}
|
|
311
|
+
return { ok: true, mode: 'exact', pair: matches[0], reason: 'exact-model-unique', alternatives: await poolFor(matches[0]), failures }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// auto/fast/pro:按能力过滤 + 偏好排序
|
|
315
|
+
const { scored, usable } = await rank()
|
|
316
|
+
if (usable.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false, mode, reason: 'no-compatible-candidate',
|
|
319
|
+
rejected: scored.filter((s) => s.rejected !== undefined).slice(0, 10).map((s) => ({ provider: s.pair.provider, model: s.pair.model, rejected: s.rejected })),
|
|
320
|
+
failures,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const best = usable[0]
|
|
324
|
+
// 目录列了但 pair 无法解析能力时的诚实标注(不是"已验证可调用")
|
|
325
|
+
return {
|
|
326
|
+
ok: true, mode, pair: best.pair, reason: best.reasons.includes('deepseek-preferred') ? 'deepseek-preferred' : (best.reasons[0] || 'stable-order'),
|
|
327
|
+
reasons: best.reasons, capabilityKnown: best.capability !== undefined && best.capability.known === true,
|
|
328
|
+
alternatives: usable.slice(1, 4).map((s) => ({ provider: s.pair.provider, model: s.pair.model, score: s.score })),
|
|
329
|
+
failures, catalogAt: snap.at,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 失败分类:只用稳定 code/status。 */
|
|
334
|
+
export const classifyFailure = (failure) => {
|
|
335
|
+
const code = failure !== null && typeof failure === 'object' && typeof failure.code === 'string' ? failure.code.toUpperCase() : ''
|
|
336
|
+
const status = failure !== null && typeof failure === 'object' && Number.isFinite(failure.status) ? failure.status : undefined
|
|
337
|
+
const retryAfterMs = failure !== null && typeof failure === 'object' && Number.isFinite(failure.providerRetryAfterMs) ? failure.providerRetryAfterMs : undefined
|
|
338
|
+
if (code !== '') {
|
|
339
|
+
if (NEVER_SWITCH_CODES.has(code)) return { action: 'never', code, reason: 'not-a-model-availability-failure' }
|
|
340
|
+
if (SWITCHABLE_CODES.has(code)) return { action: 'switch', code, reason: 'structured-unavailable' }
|
|
341
|
+
if (RETRYABLE_CODES.has(code)) return { action: 'retry-then-switch', code, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'transient' }
|
|
342
|
+
// HTTP_404:只说明该路由不可用,**不断言**模型下架
|
|
343
|
+
if (/^HTTP_4\d\d$/.test(code)) return { action: code === 'HTTP_404' || code === 'HTTP_401' || code === 'HTTP_403' || code === 'HTTP_402' ? 'switch' : 'never', code, reason: code === 'HTTP_404' ? 'route-unavailable' : 'request-rejected' }
|
|
344
|
+
if (/^HTTP_5\d\d$/.test(code)) return { action: 'retry-then-switch', code, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'server' }
|
|
345
|
+
return { action: 'never', code, reason: 'unknown-code' } // 未知 code:不切(可能是插件 bug)
|
|
346
|
+
}
|
|
347
|
+
if (status !== undefined) {
|
|
348
|
+
if (status === 401 || status === 403 || status === 402 || status === 404) return { action: 'switch', code: 'HTTP_' + status, reason: 'structured-unavailable' }
|
|
349
|
+
if (status === 429) return { action: 'retry-then-switch', code: 'HTTP_429', ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'rate-limit' }
|
|
350
|
+
if (status >= 500) return { action: 'retry-then-switch', code: 'HTTP_' + status, ...(retryAfterMs === undefined ? {} : { retryAfterMs }), reason: 'server' }
|
|
351
|
+
return { action: 'never', code: 'HTTP_' + status, reason: 'request-rejected' }
|
|
352
|
+
}
|
|
353
|
+
return { action: 'never', code: '', reason: 'no-structured-failure' }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 有界切换策略默认值。 */
|
|
357
|
+
export const DEFAULT_ROUTE_LIMITS = {
|
|
358
|
+
maxCandidates: 3, // 含首次
|
|
359
|
+
maxAttemptsPerPair: 2,
|
|
360
|
+
perPairTimeoutMs: 120_000,
|
|
361
|
+
totalTimeoutMs: 300_000,
|
|
362
|
+
cooldownMs: 30_000,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 任务作用域的有界切换状态机(同一 taskId/turn/step 内使用;不重派 prompt、不新建 child)。
|
|
367
|
+
* `next()` 只回答"这一次 attempt 用什么 pair / 是否还能继续",绝不做任何 SDK 副作用。
|
|
368
|
+
*
|
|
369
|
+
* **预算语义(第 55 轮修正)**:`totalTimeoutMs`/`perPairTimeoutMs` 是**故障恢复预算**,
|
|
370
|
+
* 从"恢复 episode"(第一次故障)开始计时,**不**从建任务时计时——否则长健康任务会把预算耗光,
|
|
371
|
+
* 五分钟后的首次故障无法切换。恢复成功(下一 step/turn 的 attempt 未经过失败)会关闭 episode;
|
|
372
|
+
* 下次故障重新开 episode。retry-after 等待走真实时钟,自然计入恢复预算(不会每次 retry 重置)。
|
|
373
|
+
*/
|
|
374
|
+
export const createRouteSession = (options = {}) => {
|
|
375
|
+
const limits = { ...DEFAULT_ROUTE_LIMITS, ...(options.limits || {}) }
|
|
376
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now()
|
|
377
|
+
const cooldowns = options.cooldowns instanceof Map ? options.cooldowns : new Map()
|
|
378
|
+
const startedAt = now()
|
|
379
|
+
/** 入参 pair 可能只有 {provider,model}:**一律归一化**出 key(否则 attempted/cooldowns 会以 undefined 为键)。 */
|
|
380
|
+
const normalize = (pair) => {
|
|
381
|
+
if (pair === null || pair === undefined) return null
|
|
382
|
+
if (typeof pair.provider !== 'string' || typeof pair.model !== 'string') return null
|
|
383
|
+
return pairOf(pair.provider, pair.model)
|
|
384
|
+
}
|
|
385
|
+
let current = normalize(options.initial)
|
|
386
|
+
const attempted = new Map() // pairKey → attempts
|
|
387
|
+
const usedOrder = [] // 已尝试过的 pair(按顺序,去重)
|
|
388
|
+
const switches = []
|
|
389
|
+
let lastFailure = null
|
|
390
|
+
let lastFailureAt = null // 最近一次失败时刻(用于判断"恢复是否已成功")
|
|
391
|
+
/** 当前恢复 episode:第一次故障开启;恢复成功后关闭。null = 尚未进入恢复。 */
|
|
392
|
+
let episodeStartedAt = null
|
|
393
|
+
/** 本 episode 内每个 pair 首次故障的时刻(perPairTimeout 的真实消费者)。 */
|
|
394
|
+
const pairRecoveryAt = new Map()
|
|
395
|
+
/** 上一次 attempt 的 turn/step(判断"健康推进"用)。 */
|
|
396
|
+
let lastAttempt = null
|
|
397
|
+
let remaining = Array.isArray(options.alternatives) ? options.alternatives.map(normalize).filter((p) => p !== null) : []
|
|
398
|
+
|
|
399
|
+
const closeEpisode = () => { episodeStartedAt = null; pairRecoveryAt.clear(); lastFailureAt = null }
|
|
400
|
+
|
|
401
|
+
const pairCooldown = (pair) => {
|
|
402
|
+
const until = cooldowns.get(pair.key)
|
|
403
|
+
return Number.isFinite(until) && until > now() ? until : 0
|
|
404
|
+
}
|
|
405
|
+
const addCooldown = (pair, ms) => {
|
|
406
|
+
if (!Number.isFinite(ms) || ms <= 0) return
|
|
407
|
+
cooldowns.set(pair.key, now() + Math.min(ms, 10 * 60_000))
|
|
408
|
+
}
|
|
409
|
+
const distinctCount = () => usedOrder.length
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* 记录一次 attempt 的实际使用(由 bridge 在 agent/request 返回前调用)。
|
|
413
|
+
* `position` 可选 `{turn, step}`:若上一 attempt 之后**没有失败**且 turn/step 前进 → 说明上一 attempt
|
|
414
|
+
* 已成功、任务健康推进 → 关闭恢复 episode(下次故障重新获得完整恢复预算)。
|
|
415
|
+
*/
|
|
416
|
+
const recordAttempt = (pair, position) => {
|
|
417
|
+
const normalized = normalize(pair)
|
|
418
|
+
if (normalized === null) return
|
|
419
|
+
const turn = position !== null && typeof position === 'object' && Number.isInteger(position.turn) ? position.turn : undefined
|
|
420
|
+
const step = position !== null && typeof position === 'object' && Number.isInteger(position.step) ? position.step : undefined
|
|
421
|
+
// 位置前进 = 上一 attempt 真的产出了内容(step/turn 推进),说明恢复已成功 → 关闭 episode。
|
|
422
|
+
// 注意:**不能**再要求 lastFailureAt===null —— 恢复过程里必然有失败,正是"失败后位置前进"才算恢复成功;
|
|
423
|
+
// 同 turn/step 的重试/切换不会推进位置,因此不会误关。
|
|
424
|
+
if (lastAttempt !== null && Number.isInteger(turn) && Number.isInteger(step) && Number.isInteger(lastAttempt.turn) && Number.isInteger(lastAttempt.step)) {
|
|
425
|
+
const progressed = turn > lastAttempt.turn || (turn === lastAttempt.turn && step > lastAttempt.step)
|
|
426
|
+
if (progressed) closeEpisode()
|
|
427
|
+
}
|
|
428
|
+
const count = (attempted.get(normalized.key) || 0) + 1
|
|
429
|
+
attempted.set(normalized.key, count)
|
|
430
|
+
if (!usedOrder.some((p) => p.key === normalized.key)) usedOrder.push(normalized)
|
|
431
|
+
current = normalized
|
|
432
|
+
lastAttempt = { turn, step }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 收到结构化失败后决定下一步。返回:
|
|
437
|
+
* { action:'retry', pair, reason } → 同 turn/step 重试(可能是同 pair 有界重试,也可能是切换后的新 pair)
|
|
438
|
+
* { action:'stop', reason, exhausted } → 如实失败(不 done、不空输出、不重派)
|
|
439
|
+
*/
|
|
440
|
+
const onFailure = (failure, context = {}) => {
|
|
441
|
+
const classified = classifyFailure(failure)
|
|
442
|
+
lastFailure = classified
|
|
443
|
+
const at = now()
|
|
444
|
+
if (context.aborted === true) return { action: 'stop', reason: 'aborted', exhausted: false }
|
|
445
|
+
if (classified.action === 'never') return { action: 'stop', reason: classified.reason, code: classified.code, exhausted: false }
|
|
446
|
+
// ---- 恢复 episode:第一次故障开启;只算**恢复活动**时长,不算任务健康运行时长 ----
|
|
447
|
+
if (episodeStartedAt === null) episodeStartedAt = at
|
|
448
|
+
lastFailureAt = at
|
|
449
|
+
const currentPair = current
|
|
450
|
+
if (currentPair !== null && !pairRecoveryAt.has(currentPair.key)) pairRecoveryAt.set(currentPair.key, at)
|
|
451
|
+
// 总恢复预算(含 retry-after 等待的真实时间)
|
|
452
|
+
if (at - episodeStartedAt >= limits.totalTimeoutMs) return { action: 'stop', reason: 'total-time-limit', exhausted: true }
|
|
453
|
+
// 单 pair 恢复时限:该 pair 在本次恢复 episode 里已耗掉 perPairTimeoutMs → 不再重试它
|
|
454
|
+
const pairStart = currentPair === null ? null : pairRecoveryAt.get(currentPair.key)
|
|
455
|
+
const pairTimeUp = currentPair !== null && Number.isInteger(pairStart) && (at - pairStart >= limits.perPairTimeoutMs)
|
|
456
|
+
// 1) 同一 pair 的有界重试(限流/网络/服务端),且该 pair 的恢复时限未到
|
|
457
|
+
if (classified.action === 'retry-then-switch' && currentPair !== null && !pairTimeUp) {
|
|
458
|
+
const used = attempted.get(currentPair.key) || 0
|
|
459
|
+
if (used < limits.maxAttemptsPerPair) {
|
|
460
|
+
return { action: 'retry', pair: currentPair, reason: 'bounded-retry:' + classified.reason, ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }) }
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// 2) 切换到下一个不同候选(perPair 时限到/该 pair 次数用完/结构化不可用都走这里)
|
|
464
|
+
if (classified.action === 'switch' || classified.action === 'retry-then-switch') {
|
|
465
|
+
if (currentPair !== null) addCooldown(currentPair, classified.retryAfterMs !== undefined ? classified.retryAfterMs : limits.cooldownMs)
|
|
466
|
+
if (distinctCount() >= limits.maxCandidates) return { action: 'stop', reason: 'candidate-limit', exhausted: true }
|
|
467
|
+
while (remaining.length > 0) {
|
|
468
|
+
const candidate = remaining.shift()
|
|
469
|
+
if (attempted.has(candidate.key)) continue // 不重复选已失败候选(不绕圈)
|
|
470
|
+
const until = pairCooldown(candidate)
|
|
471
|
+
if (until > at) continue // 临时冷却中的不选
|
|
472
|
+
switches.push({ at, from: currentPair === null ? null : { provider: currentPair.provider, model: currentPair.model }, to: { provider: candidate.provider, model: candidate.model }, code: classified.code, reason: pairTimeUp ? 'per-pair-time-limit' : classified.reason })
|
|
473
|
+
current = candidate
|
|
474
|
+
return { action: 'retry', pair: candidate, reason: pairTimeUp ? 'switch:per-pair-time-limit' : 'switch:' + classified.reason, switched: true }
|
|
475
|
+
}
|
|
476
|
+
return { action: 'stop', reason: 'candidates-exhausted', exhausted: true }
|
|
477
|
+
}
|
|
478
|
+
return { action: 'stop', reason: classified.reason, code: classified.code, exhausted: false }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const summary = () => ({
|
|
482
|
+
attempts: attempted.size,
|
|
483
|
+
uses: Array.from(attempted.entries()),
|
|
484
|
+
switches: switches.length,
|
|
485
|
+
switchLog: switches.slice(0, 20),
|
|
486
|
+
lastFailure,
|
|
487
|
+
initial: options.initial === undefined ? null : options.initial,
|
|
488
|
+
current: current === null ? null : { provider: current.provider, model: current.model },
|
|
489
|
+
/** 任务存活时长(仅诊断;**不**参与恢复预算)。 */
|
|
490
|
+
elapsedMs: now() - startedAt,
|
|
491
|
+
/** 当前恢复 episode 已用时长(没有 episode 时为 0)。 */
|
|
492
|
+
recoveryElapsedMs: episodeStartedAt === null ? 0 : now() - episodeStartedAt,
|
|
493
|
+
episodeOpen: episodeStartedAt !== null,
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
return { recordAttempt, onFailure, summary, limits, _state: () => ({ attempted, usedOrder, remaining }) }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 供审计/状态回显使用的非秘密摘要。 */
|
|
500
|
+
export const routeSummaryOf = (session) => (session === null || session === undefined ? null : session.summary())
|