@lqc123qwe/car-runtime 1.13.0 → 1.14.0

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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * 1.14-S4 · K-POOL-1 模型池网关(D-42:登记 + health 探测 + fallback 链 + retry 策略 + routing/decide 落链)
3
+ *
4
+ * 口径:
5
+ * - 候选序 = priority 升序(缺省 0)→ weight 降序(缺省 0;D-42:weight 仅作排序 tie-break,
6
+ * 不做负载均衡调度)→ 登记序(稳定排序);
7
+ * - health 预检(doctor 面同款口径——值不打印):GET {baseUrl}/models(openai)/
8
+ * {baseUrl}/v1/models(anthropic);**任意 HTTP 状态码 = 传输可达(健康)**——404/401 ≠ 不健康
9
+ * (真实连通性以发消息为准,doctor 先例);网络层失败/超时 = 不健康(按序跳过,fail-visible
10
+ * 落 routing/decide attempts);结果 TTL 缓存(防死主端点每请求烧重试预算);probeImpl 测试注入缝;
11
+ * - fallback 链 = **首块前失败才可降级**(已消费 chunk 不可重放——llm.ts §3.5.4 同红线):
12
+ * 成员请求抛错(网络/429/5xx 重试耗尽/凭据缺失)或首 chunk 即 error chunk(4xx 业务错,零内容
13
+ * 消费)→ 按序降级下一候选;流中途失败 = error chunk 原样收口不降级;
14
+ * - retry 策略 = 成员级重试(§3.5.4 基线,装配层透传)+ 池级 maxAttempts 候选请求尝试上限
15
+ * (缺省 = 端点数;耗尽 = providerUnreachable B080001——runTurn 以 error 收口);
16
+ * - **routing/decide 审计事件**:每次决策一条(payload.type='decide':selected/reason/retries/
17
+ * attempts)——onDecide 回调由宿主接线 SessionLog.append(事件链只追加红线;禁静默换模型);
18
+ * retries = 发起请求且失败的候选数(健康跳过不计,attempts 全量在册);
19
+ * - 成员请求 req.model/maxTokens 按成员改写(成员适配器消费自身 model——usage 按成员模型落账);
20
+ * - 无池配置 = 行为与 1.13 逐字节一致(兼容验收线——池路由零介入);
21
+ * - 明文凭据红线:成员凭据仅收 keyEnv env 变量名引用(config schema 明文键显式拒绝,CR-05)。
22
+ */
23
+ import { request as httpRequest } from 'node:http'
24
+ import { request as httpsRequest } from 'node:https'
25
+ import type { ModelPoolConfig } from './config.ts'
26
+ import { createAnthropicAdapter, createOpenAICompatAdapter } from '../runtime-core/llm.ts'
27
+ import { CredentialService } from '../runtime-core/credentials.ts'
28
+ import { providerUnreachable } from '../runtime-core/errors.ts'
29
+ import type { LlmAdapter, LlmChunk, LlmRequest } from '../runtime-core/types.ts'
30
+
31
+ /** 池成员(解析后形态——id 缺省已派生;候选序已定) */
32
+ export interface PoolMember {
33
+ id: string
34
+ baseUrl: string
35
+ model: string
36
+ adapterId: 'openai-compat' | 'anthropic'
37
+ keyEnv?: string
38
+ maxTokens?: number
39
+ priority?: number
40
+ weight?: number
41
+ }
42
+
43
+ /** 候选序:priority 升序(缺省 0)→ weight 降序(缺省 0,D-42 tie-break)→ 登记序(稳定) */
44
+ export function orderPoolMembers(members: PoolMember[]): PoolMember[] {
45
+ return members
46
+ .map((m, i) => ({ m, i }))
47
+ .sort((a, b) => (a.m.priority ?? 0) - (b.m.priority ?? 0) || (b.m.weight ?? 0) - (a.m.weight ?? 0) || a.i - b.i)
48
+ .map(x => x.m)
49
+ }
50
+
51
+ /** config modelPool → 池成员(id 缺省 ep-1..N 派生;重复 id 显式拒绝;候选序就位) */
52
+ export function resolveModelPoolMembers(pool: ModelPoolConfig): { members: PoolMember[]; error?: string } {
53
+ const seen = new Set<string>()
54
+ const members: PoolMember[] = []
55
+ for (const [i, ep] of pool.endpoints.entries()) {
56
+ const id = ep.id ?? `ep-${i + 1}`
57
+ if (seen.has(id)) {
58
+ return { members: [], error: `CAR-E-CONFIG: 模型池端点 id "${id}" 重复(routing/decide 落链定位歧义禁入)` }
59
+ }
60
+ seen.add(id)
61
+ members.push({
62
+ id,
63
+ baseUrl: ep.baseUrl,
64
+ model: ep.model,
65
+ adapterId: ep.adapterId ?? 'openai-compat',
66
+ ...(ep.keyEnv !== undefined ? { keyEnv: ep.keyEnv } : {}),
67
+ ...(ep.maxTokens !== undefined ? { maxTokens: ep.maxTokens } : {}),
68
+ ...(ep.priority !== undefined ? { priority: ep.priority } : {}),
69
+ ...(ep.weight !== undefined ? { weight: ep.weight } : {}),
70
+ })
71
+ }
72
+ return { members: orderPoolMembers(members) }
73
+ }
74
+
75
+ // ==================== health 预检(doctor 面同款口径) ====================
76
+
77
+ export interface PoolMemberHealth {
78
+ /** 传输可达(任意 HTTP 状态码)= true;网络层失败/超时 = false */
79
+ ok: boolean
80
+ status?: number
81
+ latencyMs: number
82
+ /** 不健康原因类别(值不打印纪律——只出类别) */
83
+ errorCategory?: string
84
+ }
85
+
86
+ /** node:http(s) GET 状态码(1.8-BUG-1 出站通道口径——全局 fetch/undici win32 退出断言预防) */
87
+ function httpGetStatus(url: string, signal: AbortSignal): Promise<number> {
88
+ return new Promise((resolve, reject) => {
89
+ try {
90
+ const u = new URL(url)
91
+ const mod = u.protocol === 'https:' ? httpsRequest : httpRequest
92
+ const req = mod(u, { method: 'GET', headers: { accept: 'application/json' } }, res => {
93
+ res.resume() // 排干 body——连接收口
94
+ resolve(res.statusCode ?? 0)
95
+ })
96
+ const onAbort = () => { try { req.destroy(new Error('aborted')) } catch { /* 已完成 */ } }
97
+ if (signal.aborted) onAbort()
98
+ else signal.addEventListener('abort', onAbort, { once: true })
99
+ req.on('error', reject)
100
+ req.end()
101
+ } catch (e) { reject(e) }
102
+ })
103
+ }
104
+
105
+ /** 单端点 health 探测(doctor/池预检复用;值不打印——只出状态/延迟/类别) */
106
+ export async function probePoolMember(
107
+ member: Pick<PoolMember, 'baseUrl' | 'adapterId'>,
108
+ opts: { timeoutMs?: number } = {},
109
+ ): Promise<PoolMemberHealth> {
110
+ const base = member.baseUrl.replace(/\/+$/, '')
111
+ const url = member.adapterId === 'anthropic' ? `${base}/v1/models` : `${base}/models`
112
+ const t0 = Date.now()
113
+ const controller = new AbortController()
114
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 4000)
115
+ try {
116
+ const status = await httpGetStatus(url, controller.signal)
117
+ return { ok: true, status, latencyMs: Date.now() - t0 }
118
+ } catch (e) {
119
+ const aborted = (e as Error).name === 'AbortError' || String((e as Error).message).includes('aborted')
120
+ return { ok: false, latencyMs: Date.now() - t0, errorCategory: aborted ? 'timeout' : 'network-error' }
121
+ } finally {
122
+ clearTimeout(timer)
123
+ }
124
+ }
125
+
126
+ // ==================== 路由引擎(fallback 链 + retry 上限 + routing/decide) ====================
127
+
128
+ /** 逐候选结果(attempts 全量在册——健康跳过与请求失败都显性化) */
129
+ export interface RoutingAttempt {
130
+ id: string
131
+ outcome: 'selected' | 'failed' | 'skipped-unhealthy'
132
+ /** 失败/跳过类别(错误码/名称,值不打印) */
133
+ detail?: string
134
+ }
135
+
136
+ /** 路由决策(routing/decide 事件 payload 源——每次请求一条) */
137
+ export interface RoutingDecision {
138
+ /** 选中端点 id(耗尽 = null) */
139
+ selected: string | null
140
+ /** 首位候选选中 = primary;降级选中 = fallback;全部不可用 = exhausted */
141
+ reason: 'primary' | 'fallback' | 'exhausted'
142
+ /** 降级重试次数 = 发起请求且失败的候选数(健康跳过不计——attempts 全量在册) */
143
+ retries: number
144
+ attempts: RoutingAttempt[]
145
+ turnId?: string
146
+ sessionId?: string
147
+ }
148
+
149
+ export interface PoolAdapterOptions {
150
+ id?: string
151
+ /** 成员与适配器(装配层产出的候选序) */
152
+ members: Array<{ member: PoolMember; adapter: LlmAdapter }>
153
+ /** health 预检(enabled 缺省 true;probeImpl 测试注入缝——缺省真实 GET 探测) */
154
+ probe?: { enabled?: boolean; timeoutMs?: number; ttlMs?: number; probeImpl?: typeof probePoolMember }
155
+ /** 候选请求尝试上限(缺省 = 成员数) */
156
+ maxAttempts?: number
157
+ /** routing/decide 审计回调(宿主接线 SessionLog.append——禁静默换模型红线) */
158
+ onDecide?: (d: RoutingDecision) => void
159
+ }
160
+
161
+ const errorCategory = (e: unknown): string => {
162
+ const err = e as { slug?: string; name?: string }
163
+ return err.slug ?? err.name ?? 'unknown'
164
+ }
165
+
166
+ export function createModelPoolAdapter(opts: PoolAdapterOptions): LlmAdapter & { id: string; members: PoolMember[] } {
167
+ const ordered = opts.members
168
+ const maxAttempts = opts.maxAttempts ?? ordered.length
169
+ const probeEnabled = opts.probe?.enabled !== false
170
+ const probeFn = opts.probe?.probeImpl ?? probePoolMember
171
+ const probeTimeoutMs = opts.probe?.timeoutMs ?? 4000
172
+ const ttlMs = opts.probe?.ttlMs ?? 30_000
173
+ const healthCache = new Map<string, { ok: boolean; at: number; category?: string }>()
174
+ const id = opts.id ?? 'model-pool'
175
+
176
+ const healthOf = async (m: PoolMember): Promise<{ ok: boolean; category?: string }> => {
177
+ const hit = healthCache.get(m.id)
178
+ if (hit && Date.now() - hit.at < ttlMs) {
179
+ return { ok: hit.ok, ...(hit.category !== undefined ? { category: hit.category } : {}) }
180
+ }
181
+ const h = await probeFn(m, { timeoutMs: probeTimeoutMs })
182
+ const entry = { ok: h.ok, at: Date.now(), ...(h.errorCategory !== undefined ? { category: h.errorCategory } : {}) }
183
+ healthCache.set(m.id, entry)
184
+ return { ok: entry.ok, ...(entry.category !== undefined ? { category: entry.category } : {}) }
185
+ }
186
+
187
+ const withMeta = (d: Omit<RoutingDecision, 'turnId' | 'sessionId'>, req: LlmRequest): RoutingDecision => ({
188
+ ...d,
189
+ ...(req.metadata?.turnId !== undefined ? { turnId: req.metadata.turnId } : {}),
190
+ ...(req.metadata?.sessionId !== undefined ? { sessionId: req.metadata.sessionId } : {}),
191
+ })
192
+
193
+ return {
194
+ id,
195
+ members: ordered.map(x => x.member),
196
+ chat(req: LlmRequest): AsyncIterable<LlmChunk> {
197
+ return (async function* () {
198
+ const attempts: RoutingAttempt[] = []
199
+ let failedCount = 0
200
+ let requestAttempts = 0
201
+ for (const [pos, entry] of ordered.entries()) {
202
+ if (requestAttempts >= maxAttempts) break
203
+ if (probeEnabled) {
204
+ const h = await healthOf(entry.member)
205
+ if (!h.ok) {
206
+ attempts.push({ id: entry.member.id, outcome: 'skipped-unhealthy', ...(h.category !== undefined ? { detail: h.category } : {}) })
207
+ continue
208
+ }
209
+ }
210
+ requestAttempts++
211
+ // 成员请求:req.model/maxTokens 按成员改写(usage 按成员模型落账)
212
+ let it: AsyncIterator<LlmChunk>
213
+ try {
214
+ it = entry.adapter.chat({
215
+ ...req,
216
+ model: entry.member.model,
217
+ ...(entry.member.maxTokens != null ? { maxTokens: entry.member.maxTokens } : {}),
218
+ })[Symbol.asyncIterator]()
219
+ } catch (e) {
220
+ // 适配器同步抛错(契约违规/装配错误)= 候选不可用,降级下一候选
221
+ attempts.push({ id: entry.member.id, outcome: 'failed', detail: errorCategory(e) })
222
+ failedCount++
223
+ continue
224
+ }
225
+ try {
226
+ let first: IteratorResult<LlmChunk>
227
+ try {
228
+ first = await it.next()
229
+ } catch (e) {
230
+ // 首块前失败(网络/429/5xx 重试耗尽/凭据缺失)→ 降级下一候选
231
+ attempts.push({ id: entry.member.id, outcome: 'failed', detail: errorCategory(e) })
232
+ failedCount++
233
+ continue
234
+ }
235
+ if (first.done || !first.value) {
236
+ attempts.push({ id: entry.member.id, outcome: 'failed', detail: 'empty-stream' })
237
+ failedCount++
238
+ continue
239
+ }
240
+ const fc = first.value
241
+ // 首 chunk 即 error chunk 且零内容消费(4xx 业务错)→ 候选不可用,降级(零 chunk 已出——可重放安全)
242
+ const errorOnly = fc.finishReason === 'error' && fc.delta === undefined && fc.toolCallDelta === undefined
243
+ if (errorOnly) {
244
+ attempts.push({ id: entry.member.id, outcome: 'failed', detail: fc.error ? 'http-4xx' : 'error-chunk' })
245
+ failedCount++
246
+ continue
247
+ }
248
+ // 选中:routing/decide 落链(禁静默换模型——选中端点/原因/重试次数显性化)
249
+ attempts.push({ id: entry.member.id, outcome: 'selected' })
250
+ opts.onDecide?.(withMeta({
251
+ selected: entry.member.id,
252
+ reason: pos === 0 ? 'primary' : 'fallback',
253
+ retries: failedCount,
254
+ attempts,
255
+ }, req))
256
+ yield fc
257
+ while (true) {
258
+ const r = await it.next()
259
+ if (r.done) break
260
+ yield r.value // 流中途失败 = error chunk 原样收口(已消费 chunk 不可重放——不降级)
261
+ }
262
+ return
263
+ } finally {
264
+ // 成员迭代器收口(首 chunk 后未耗尽路径——4xx 降级/取消/异常;自然结束路径 no-op)
265
+ await it.return?.(undefined)
266
+ }
267
+ }
268
+ // 耗尽:决策落链 + B080001(runTurn error 收口——BD-04)
269
+ opts.onDecide?.(withMeta({ selected: null, reason: 'exhausted', retries: failedCount, attempts }, req))
270
+ const summary = attempts.map(a => `${a.id}:${a.outcome}${a.detail ? `(${a.detail})` : ''}`).join(', ') || '无候选'
271
+ throw providerUnreachable(`模型池全部候选不可用(${summary})`)
272
+ })()
273
+ },
274
+ }
275
+ }
276
+
277
+ // ==================== 池装配层(config → 成员适配器 → 路由适配器) ====================
278
+
279
+ export interface ModelPoolAssemblyOptions {
280
+ pool: ModelPoolConfig
281
+ /** 共享凭据服务(成员级 keyEnv 经 credentialEnvVars 精确候选) */
282
+ credentials?: CredentialService
283
+ /** 测试注入(成员适配器 fetch/退避——缺省真实通道) */
284
+ fetchImpl?: typeof fetch
285
+ timeoutMs?: number
286
+ firstByteMs?: number
287
+ retries?: number
288
+ backoffMs?: number[]
289
+ sleep?: (ms: number) => Promise<void>
290
+ /** routing/decide 审计回调(透传路由引擎) */
291
+ onDecide?: (d: RoutingDecision) => void
292
+ }
293
+
294
+ /** 池装配:config 解析(候选序/重复拒绝)→ 成员适配器(内置两族 + keyEnv)→ 路由适配器 */
295
+ export function assembleModelPool(opts: ModelPoolAssemblyOptions): {
296
+ adapter: LlmAdapter & { id: string; members: PoolMember[] }
297
+ members: PoolMember[]
298
+ } {
299
+ const { members, error } = resolveModelPoolMembers(opts.pool)
300
+ if (error) throw new Error(error)
301
+ const entries = members.map(m => ({
302
+ member: m,
303
+ adapter: (m.adapterId === 'anthropic'
304
+ ? createAnthropicAdapter({
305
+ baseUrl: m.baseUrl,
306
+ credentials: opts.credentials,
307
+ ...(m.keyEnv !== undefined ? { credentialEnvVars: [m.keyEnv] } : {}),
308
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
309
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
310
+ ...(opts.firstByteMs !== undefined ? { firstByteMs: opts.firstByteMs } : {}),
311
+ ...(opts.retries !== undefined ? { retries: opts.retries } : {}),
312
+ ...(opts.backoffMs !== undefined ? { backoffMs: opts.backoffMs } : {}),
313
+ ...(opts.sleep ? { sleep: opts.sleep } : {}),
314
+ })
315
+ : createOpenAICompatAdapter({
316
+ baseUrl: m.baseUrl,
317
+ credentials: opts.credentials,
318
+ provider: 'openai-compat',
319
+ ...(m.keyEnv !== undefined ? { credentialEnvVars: [m.keyEnv] } : {}),
320
+ ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}),
321
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
322
+ ...(opts.firstByteMs !== undefined ? { firstByteMs: opts.firstByteMs } : {}),
323
+ ...(opts.retries !== undefined ? { retries: opts.retries } : {}),
324
+ ...(opts.backoffMs !== undefined ? { backoffMs: opts.backoffMs } : {}),
325
+ ...(opts.sleep ? { sleep: opts.sleep } : {}),
326
+ })),
327
+ }))
328
+ const adapter = createModelPoolAdapter({
329
+ members: entries,
330
+ probe: {
331
+ ...(opts.pool.probe?.enabled !== undefined ? { enabled: opts.pool.probe.enabled } : {}),
332
+ ...(opts.pool.probe?.timeoutMs !== undefined ? { timeoutMs: opts.pool.probe.timeoutMs } : {}),
333
+ ...(opts.pool.probe?.ttlMs !== undefined ? { ttlMs: opts.pool.probe.ttlMs } : {}),
334
+ },
335
+ ...(opts.pool.maxAttempts !== undefined ? { maxAttempts: opts.pool.maxAttempts } : {}),
336
+ ...(opts.onDecide ? { onDecide: opts.onDecide } : {}),
337
+ })
338
+ return { adapter, members }
339
+ }
package/src/loop/stop.ts CHANGED
@@ -72,12 +72,13 @@ export class TurnAborted extends Error {
72
72
  * 在 JSONL,聚合由事实行求和而非运行时状态;字段非数值容错跳过不吞计数)。无 usage 事件
73
73
  * 返回 undefined → turnEnd meta 不带 usage 键(缺省=未采集,旧格式向后兼容)。
74
74
  */
75
- function turnUsageFrom(log: SessionLog, turnId: string): { input: number; output: number; cacheRead: number; cacheCreation: number; requests: number } | undefined {
75
+ function turnUsageFrom(log: SessionLog, turnId: string): { input: number; output: number; cacheRead: number; cacheCreation: number; requests: number; segments?: { reasoningTokens: number } } | undefined {
76
76
  let input = 0
77
77
  let output = 0
78
78
  let cacheRead = 0
79
79
  let cacheCreation = 0
80
80
  let requests = 0
81
+ let reasoning: number | undefined
81
82
  for (const e of log.events) {
82
83
  if (e.kind !== 'usage' || e.turnId !== turnId) continue
83
84
  const p = (e.payload ?? {}) as Record<string, unknown>
@@ -85,9 +86,14 @@ function turnUsageFrom(log: SessionLog, turnId: string): { input: number; output
85
86
  if (typeof p.output === 'number') output += p.output
86
87
  if (typeof p.cacheRead === 'number') cacheRead += p.cacheRead
87
88
  if (typeof p.cacheCreation === 'number') cacheCreation += p.cacheCreation
89
+ // 1.14-S3(K-MP-2b / D-41):分段聚合——任一请求携带 segments.reasoningTokens 即求和;
90
+ // 全 turn 无分段事实 = meta.usage 不带 segments 键(禁估算补造)
91
+ const seg = (p.segments ?? null) as { reasoningTokens?: unknown } | null
92
+ if (seg && typeof seg.reasoningTokens === 'number') reasoning = (reasoning ?? 0) + seg.reasoningTokens
88
93
  requests++
89
94
  }
90
- return requests ? { input, output, cacheRead, cacheCreation, requests } : undefined
95
+ if (!requests) return undefined
96
+ return { input, output, cacheRead, cacheCreation, requests, ...(reasoning !== undefined ? { segments: { reasoningTokens: reasoning } } : {}) }
91
97
  }
92
98
 
93
99
  export async function runTurn(opts: {
@@ -117,6 +117,8 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
117
117
  output: usage.outputTokens,
118
118
  cacheRead: usage.cacheReadTokens,
119
119
  cacheCreation: usage.cacheCreationTokens,
120
+ // 1.14-S3(K-MP-2b / D-41):分段事实随事件落链——无分段 = 不带键(禁估算)
121
+ ...(usage.segments ? { segments: usage.segments } : {}),
120
122
  providerRaw: usage.providerRaw,
121
123
  })
122
124
  }
@@ -28,6 +28,8 @@ export interface CredentialResolveOptions {
28
28
  keychainReader?: (service: string) => string | null
29
29
  /** reveal 审计回调(值不入参——审计全量留痕但零明文) */
30
30
  audit?: (event: { action: 'resolve' | 'reveal'; provider: string; source: CredentialSource; origin: string }) => void
31
+ /** 1.14-S4(K-POOL-1):显式 env 变量候选(modelPool keyEnv 精确引用——替代 provider 缺省表;keychain 链保持在前) */
32
+ envVarCandidates?: string[]
31
33
  }
32
34
 
33
35
  /** provider → 候选 env 变量(按序检索;CAR_LLM_API_KEY 为通用兜底) */
@@ -107,7 +109,8 @@ export class CredentialService {
107
109
 
108
110
  const allowEnv = opts.allowEnvFallback === true || env.CAR_ALLOW_ENV_CREDENTIALS === '1'
109
111
  if (allowEnv) {
110
- const candidates = providerEnvVars(provider)
112
+ // 1.14-S4(K-POOL-1):显式候选(keyEnv 精确引用)压过 provider 缺省表——用户点名变量即显式意见
113
+ const candidates = opts.envVarCandidates?.length ? opts.envVarCandidates : providerEnvVars(provider)
111
114
  for (const name of candidates) {
112
115
  searched.push(`env:${name}`)
113
116
  if (env[name]) {
@@ -25,6 +25,11 @@
25
25
  * 解析「先摘 evt.usage 再判 choice」(空 choices 尾包不再被吞);anthropic message_start(input 侧)
26
26
  * + message_delta(output 侧)两包合并(message_delta 缺席 = output 不完整,整条不落);
27
27
  * 归一口径 §1.1(openai 减法 / anthropic 透传 + cache_creation),畸形/缺失整条不落(禁补造)。
28
+ * - 1.14-S1(K-MP-1 / D-39):思考参数透传——LlmRequest.reasoning.effort 按家族映射:
29
+ * openai-compat = `reasoning_effort`;anthropic = `thinking:{type:'enabled',budget_tokens}`
30
+ * (ANTHROPIC_THINKING_BUDGET 表);**off / 缺席 = 字节面不下发**;未知 effort 值显式报错
31
+ * (CAR-E-LLM-REASONING,fail-visible 禁静默忽略——宿主越型传入防线);anthropic 侧
32
+ * budget ≥ max_tokens 违反 API 约束 → 显式报错提示调高(禁静默改写 max_tokens)。
28
33
  */
29
34
  import { CarM8Error, providerUnreachable } from './errors.ts'
30
35
  import { CredentialService } from './credentials.ts'
@@ -72,6 +77,22 @@ export class AdapterRegistry {
72
77
  }
73
78
  }
74
79
 
80
+ // ==================== 1.14-S1(K-MP-1 / D-39)思考参数映射 ====================
81
+
82
+ /** 归一思考档位:缺席/off → undefined(不下发);low/medium/high 原样;未知值 fail-visible(宿主越型传入防线) */
83
+ export function normalizeReasoningEffort(effort: unknown): 'off' | 'low' | 'medium' | 'high' | undefined {
84
+ if (effort === undefined || effort === null) return undefined
85
+ if (effort === 'off' || effort === 'low' || effort === 'medium' || effort === 'high') return effort
86
+ throw new Error(`CAR-E-LLM-REASONING: 未知思考档位 "${String(effort)}"(合法:off/low/medium/high;off/缺席 = 不下发)`)
87
+ }
88
+
89
+ /** anthropic effort → thinking.budget_tokens 映射(D-39 登记表);off/缺席不下发 */
90
+ const ANTHROPIC_THINKING_BUDGET: Record<'low' | 'medium' | 'high', number> = {
91
+ low: 2048,
92
+ medium: 8192,
93
+ high: 16384,
94
+ }
95
+
75
96
  // ==================== finishReason 不可变守卫(SDK 边界) ====================
76
97
 
77
98
  /** 首个 finishReason 定格;适配器后续给出不同值 = 契约违规显式抛错(不改写不吞没) */
@@ -264,6 +285,8 @@ export interface OpenAICompatOptions {
264
285
  /** 凭据服务与 provider 名(Bearer token 来源) */
265
286
  credentials?: CredentialService
266
287
  provider?: string
288
+ /** 1.14-S4(K-POOL-1):成员级凭据 env 变量名引用(modelPool keyEnv——resolve 精确候选;keychain 链保持在前) */
289
+ credentialEnvVars?: string[]
267
290
  fetchImpl?: typeof fetch
268
291
  /** §3.5.4 基线:总 120s / 首字节 30s / 2 次退避 1s-2s(测试可注入缩短) */
269
292
  timeoutMs?: number
@@ -288,6 +311,9 @@ class Retryable extends Error {}
288
311
  * 1.11-S1(D-33 / 计量设计 §1.1):openai usage 归一——prompt_tokens 含 cached,inputTokens
289
312
  * 必须做减法(prompt − prompt_tokens_details.cached_tokens);cache_creation 恒 0(无对应字段,
290
313
  * 登记不估算)。usage 对象缺席/畸形(两侧主字段任一非数值)= 整条不落(缺失口径,禁补造)。
314
+ * 1.14-S3(K-MP-2b / D-41):分段计量——completion_tokens_details.reasoning_tokens(思考输出
315
+ * 细分,provider 真实事实)数值才采(segments.reasoningTokens);details/字段缺席或非数值 =
316
+ * segments 不带键(禁估算补造)。
291
317
  */
292
318
  function normalizeOpenAIUsage(u: unknown, model: string, adapterId: string): LlmUsage | undefined {
293
319
  if (!u || typeof u !== 'object') return undefined
@@ -295,6 +321,8 @@ function normalizeOpenAIUsage(u: unknown, model: string, adapterId: string): Llm
295
321
  if (typeof r.prompt_tokens !== 'number' || typeof r.completion_tokens !== 'number') return undefined
296
322
  const details = (r.prompt_tokens_details ?? null) as Record<string, unknown> | null
297
323
  const cached = details && typeof details.cached_tokens === 'number' ? details.cached_tokens : 0
324
+ const compDetails = (r.completion_tokens_details ?? null) as Record<string, unknown> | null
325
+ const reasoning = compDetails && typeof compDetails.reasoning_tokens === 'number' ? compDetails.reasoning_tokens : undefined
298
326
  return {
299
327
  inputTokens: r.prompt_tokens - cached,
300
328
  outputTokens: r.completion_tokens,
@@ -303,6 +331,7 @@ function normalizeOpenAIUsage(u: unknown, model: string, adapterId: string): Llm
303
331
  model,
304
332
  adapterId,
305
333
  providerRaw: u,
334
+ ...(reasoning !== undefined ? { segments: { reasoningTokens: reasoning } } : {}),
306
335
  }
307
336
  }
308
337
 
@@ -394,6 +423,10 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
394
423
  const firstByteMs = opts.firstByteMs ?? 30_000
395
424
  const retries = opts.retries ?? 2
396
425
  const backoffMs = opts.backoffMs ?? [1_000, 2_000]
426
+ // 1.14-S4(K-POOL-1):成员级 keyEnv 精确候选——keyEnv 引用即显式意见(env 通道开启;候选=仅该变量,keychain 链保持在前)
427
+ const credResolveOpts = opts.credentialEnvVars?.length
428
+ ? { envVarCandidates: opts.credentialEnvVars, allowEnvFallback: true }
429
+ : {}
397
430
 
398
431
  const toOpenAI = (req: LlmRequest, apiKey: string) => ({
399
432
  // 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
@@ -406,6 +439,11 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
406
439
  body: JSON.stringify({
407
440
  model: req.model,
408
441
  stream: true,
442
+ // 1.14-S1(K-MP-1 / D-39):思考参数——effort 非 off/缺席才下发 reasoning_effort(off = 字节面零痕迹)
443
+ ...(normalizeReasoningEffort(req.reasoning?.effort) !== undefined &&
444
+ normalizeReasoningEffort(req.reasoning?.effort) !== 'off'
445
+ ? { reasoning_effort: normalizeReasoningEffort(req.reasoning?.effort) }
446
+ : {}),
409
447
  // 1.11-S1(D-33 / 计量设计 §9-2 推荐A):usage 采集默认开启;第三方网关不兼容时的
410
448
  // 回退方案(baseUrl 白名单)登记可回退项——不支持网关走缺失口径(禁估算补造)
411
449
  stream_options: { include_usage: true },
@@ -439,7 +477,7 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
439
477
  buildRequest: async () => {
440
478
  let apiKey = ''
441
479
  if (opts.credentials) {
442
- const ref = opts.credentials.resolve(opts.provider ?? id, {})
480
+ const ref = opts.credentials.resolve(opts.provider ?? id, credResolveOpts)
443
481
  apiKey = opts.credentials.reveal(ref, {})
444
482
  }
445
483
  return toOpenAI(req, apiKey)
@@ -470,6 +508,8 @@ export interface AnthropicAdapterOptions {
470
508
  sleep?: (ms: number) => Promise<void>
471
509
  /** D-20:Anthropic API max_tokens 必填——req.maxTokens 缺席时取此缺省(provider 差异登记,缺省 4096) */
472
510
  defaultMaxTokens?: number
511
+ /** 1.14-S4(K-POOL-1):成员级凭据 env 变量名引用(modelPool keyEnv——resolve 精确候选;keychain 链保持在前) */
512
+ credentialEnvVars?: string[]
473
513
  }
474
514
 
475
515
  /** stop_reason → CAR finishReason(未映射 fail-visible——openai FINISH_MAP 同纪律) */
@@ -495,6 +535,8 @@ function mergeAnthropicUsage(
495
535
  ): LlmUsage | undefined {
496
536
  if (!start || !delta) return undefined
497
537
  if (typeof start.input_tokens !== 'number' || typeof delta.output_tokens !== 'number') return undefined
538
+ // 1.14-S3(K-MP-2b / D-41):anthropic 无输出细分事实(thinking 计入 output_tokens)→
539
+ // segments 不带键(禁估算补造——桌面色条退化单色,登记口径)
498
540
  return {
499
541
  inputTokens: start.input_tokens,
500
542
  outputTokens: delta.output_tokens,
@@ -545,6 +587,15 @@ function toAnthropic(req: LlmRequest, apiKey: string) {
545
587
  }
546
588
  }
547
589
  flushToolResults()
590
+ // 1.14-S1(K-MP-1 / D-39):思考参数——effort → thinking.budget_tokens(ANTHROPIC_THINKING_BUDGET 表);
591
+ // off/缺席不下发;budget ≥ max_tokens 违反 Messages API 约束(max_tokens 必须大于 budget_tokens)→
592
+ // 显式报错提示调高(fail-visible,禁静默改写 max_tokens)
593
+ const reasoningEffort = normalizeReasoningEffort(req.reasoning?.effort)
594
+ const thinkingBudget = reasoningEffort && reasoningEffort !== 'off' ? ANTHROPIC_THINKING_BUDGET[reasoningEffort] : undefined
595
+ const maxTokens = req.maxTokens ?? 4096
596
+ if (thinkingBudget !== undefined && thinkingBudget >= maxTokens) {
597
+ throw new Error(`CAR-E-LLM-REASONING: thinking budget_tokens(${thinkingBudget}) 必须小于 max_tokens(${maxTokens})——请调高 maxTokens(禁静默改写请求参数)`)
598
+ }
548
599
  return {
549
600
  method: 'POST' as const,
550
601
  headers: {
@@ -555,7 +606,8 @@ function toAnthropic(req: LlmRequest, apiKey: string) {
555
606
  body: JSON.stringify({
556
607
  model: req.model,
557
608
  stream: true,
558
- max_tokens: req.maxTokens ?? 4096,
609
+ max_tokens: maxTokens,
610
+ ...(thinkingBudget !== undefined ? { thinking: { type: 'enabled', budget_tokens: thinkingBudget } } : {}),
559
611
  ...(system.length ? { system: system.join('\n\n') } : {}),
560
612
  messages: turns,
561
613
  ...(req.tools.length
@@ -639,6 +691,10 @@ export function createAnthropicAdapter(opts: AnthropicAdapterOptions): LlmAdapte
639
691
  const firstByteMs = opts.firstByteMs ?? 30_000
640
692
  const retries = opts.retries ?? 2
641
693
  const backoffMs = opts.backoffMs ?? [1_000, 2_000]
694
+ // 1.14-S4(K-POOL-1):成员级 keyEnv 精确候选(同 openai-compat 口径)
695
+ const credResolveOpts = opts.credentialEnvVars?.length
696
+ ? { envVarCandidates: opts.credentialEnvVars, allowEnvFallback: true }
697
+ : {}
642
698
 
643
699
  return {
644
700
  id,
@@ -650,7 +706,7 @@ export function createAnthropicAdapter(opts: AnthropicAdapterOptions): LlmAdapte
650
706
  buildRequest: async () => {
651
707
  let apiKey = ''
652
708
  if (opts.credentials) {
653
- const ref = opts.credentials.resolve(opts.provider ?? id, {})
709
+ const ref = opts.credentials.resolve(opts.provider ?? id, credResolveOpts)
654
710
  apiKey = opts.credentials.reveal(ref, {})
655
711
  }
656
712
  return toAnthropic(req, apiKey)
@@ -25,6 +25,10 @@ export interface LlmRequest {
25
25
  /** 1.4-S4(D-19):turn 取消信号(runTurn 的 {aborted} 形态透传)——适配器在 attempt 前 /
26
26
  * chunk 间隙检查;外部取消 ≠ 失败(不 retry、不产 error chunk,静默收口由 chatStep 抛 TurnAborted) */
27
27
  signal?: { aborted: boolean }
28
+ /** 1.14-S1(K-MP-1 / D-39):思考参数——适配器按家族映射(openai-compat = reasoning_effort;
29
+ * anthropic = thinking.budget_tokens);**off / 缺席 = 不下发该字段**(字节面零痕迹,非「effort:none」);
30
+ * 模型思考档位(模型级思考如 R1)为宿主侧模型元数据事实源(桌面 models:meta),内核不做档位判断 */
31
+ reasoning?: { effort: 'off' | 'low' | 'medium' | 'high' }
28
32
  }
29
33
 
30
34
  /** 工具调用流式增量(跨 chunk 聚合出完整 ToolCall) */
@@ -53,6 +57,13 @@ export interface LlmUsage {
53
57
  adapterId: string
54
58
  /** provider 原始 usage 对象(anthropic 为 messageStart/messageDelta 两原始片段) */
55
59
  providerRaw: unknown
60
+ /**
61
+ * 1.14-S3(K-MP-2b / D-41):请求分段计量——仅采 provider 真实返回的分段事实(归一化后),
62
+ * **无分段事实 = 不带键(禁静态估算,桌面色条退化单色不造假)**。v1 分段 = reasoningTokens
63
+ * (openai completion_tokens_details.reasoning_tokens——K-MP-1 思考参数的计量配套,thinking
64
+ * 输出细分;anthropic thinking 计入 output_tokens 无细分事实 → 缺省不带键,登记)。
65
+ */
66
+ segments?: { reasoningTokens?: number }
56
67
  }
57
68
 
58
69
  /** 流式块(finishReason 一经产生不可被任何中间层改写) */
package/src/seam.ts CHANGED
@@ -17,6 +17,11 @@
17
17
  * 只读载入面;载入/校验单一权威在内核,桌面禁自实现哈希重算)+ K-S3-3 exportForensicsBundle/verifyBundle 出面
18
18
  * (取证包导出 + 读回重验;断链即中止语义随本面)。恢复面 K-S3-2 零登记确认:SessionLog.fromEvents 自 M5 DEC-4
19
19
  * 起已随本面可达(fork.ts 生产调用点),S2 D-S2-6 前提修正登记(S3 执行期发现)。
20
+ * 加法登记:1.14.0 —— PluginManager 装载报告面(K-S4-1 / D-43):loadPlugins 出面(六阶段流水线
21
+ * discover/verify/parse/validate/topo/register——LoadReportVO 结构化 VO,verify/topo 真值取代 v0.1 桌面
22
+ * mountPlugin 组合呈现的 SKIPPED 标注)+ ReloadManager 出面(热重载 epoch 击穿 + invalidate 语义随本面)+
23
+ * verifyPluginFile 出面(签名校验挂载点——宿主自有装载路径可复用同一签名门;minisign 轨唯一实装,sigstore
24
+ * 主轨维持 verifier 骨架期预留——D-43 裁决:登记 strict 期限不引入 sigstore 依赖,engines 硬不兼容见评估报告)。
20
25
  */
21
26
  export { SessionLog, loadSessionLog, deriveSessionId, type SessionEvent, type EventKind, type Actor } from './session/log.ts'
22
27
  export { SessionFileStore } from './session/store.ts'
@@ -42,3 +47,6 @@ export type { McpClientSpanHandle, PtcRelayHandle } from './runtime-core/trace.t
42
47
  // —— 1.13.0:桌面 S3 治理面(文件安全/审计/取证)——
43
48
  export { redact, type RedactionResult } from './runtime-core/redaction.ts'
44
49
  export { exportForensicsBundle, verifyBundle, type ForensicsBundle, type ExportMeta } from './session/export.ts'
50
+ // —— 1.14.0(K-S4-1 / D-43):PluginManager 装载报告面 + 签名校验挂载点 ——
51
+ export { loadPlugins, ReloadManager, type LoadReportVO, type LoadPluginsResult, type LoadPluginsOptions, type LoadStage, type StageStatus, type PeerConflictChain } from './load/report.ts'
52
+ export { verifyPluginFile, type SignatureGateOptions, type SigGateResult } from './load/sigGate.ts'