@lqc123qwe/car-runtime 1.13.0 → 1.14.1

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.
@@ -35,12 +35,59 @@ export interface SandboxSigConfig {
35
35
  sigTrustRoot?: string
36
36
  }
37
37
 
38
+ export interface CompactionConfig {
39
+ /** 缺省 false = 行为零变化(1.13 兼容线) */
40
+ enabled?: boolean
41
+ /** 距上次压缩的新增事件数阈值(事实计数非估算);缺省 200 */
42
+ thresholdEvents?: number
43
+ /** 保护最近 N 个 turn 不折叠;缺省 4 */
44
+ protectLastTurns?: number
45
+ }
46
+
38
47
  export interface LlmConfig {
39
48
  baseUrl?: string
40
49
  model?: string
41
50
  adapterId?: string
42
51
  maxTokens?: number
43
52
  allowEnvFallback?: boolean
53
+ /** 1.14-S2(K-MP-2 / D-40):历史压缩策略(阈值自动触发判定入参;立即压缩 API 独立可用) */
54
+ compaction?: CompactionConfig
55
+ }
56
+
57
+ /** 1.14-S4(K-POOL-1 / D-42):模型池端点登记(多端点 fallback 候选;凭据仅收 keyEnv 引用) */
58
+ export interface ModelPoolEndpointSpec {
59
+ /** 端点标识(routing/decide 落链定位;缺省 ep-1..N 派生;重复显式拒绝) */
60
+ id?: string
61
+ baseUrl: string
62
+ model: string
63
+ /** 适配器家族(config 可装配集 = 内置两族;缺省 openai-compat) */
64
+ adapterId?: 'openai-compat' | 'anthropic'
65
+ /** 凭据 env 变量名引用(明文 key 显式拒绝——CR-05;缺省按 adapterId 缺省表 + CAR_LLM_API_KEY) */
66
+ keyEnv?: string
67
+ /** 优先级:数值越小越优先(缺省 0) */
68
+ priority?: number
69
+ /** 同优先级内次序:越大越先(缺省 0——D-42:登记生效为排序 tie-break,不做负载均衡调度) */
70
+ weight?: number
71
+ /** 成员级 maxTokens(缺省回落 llm.maxTokens) */
72
+ maxTokens?: number
73
+ }
74
+
75
+ /** 1.14-S4(K-POOL-1 / D-42):health 预检策略(doctor 面同款口径——值不打印) */
76
+ export interface ModelPoolProbeConfig {
77
+ /** 缺省开;不可达端点按序跳过(fail-visible 落 routing/decide attempts) */
78
+ enabled?: boolean
79
+ /** 单端点探测超时(缺省 4000ms) */
80
+ timeoutMs?: number
81
+ /** 探测结果缓存 TTL(缺省 30000ms;0 = 每请求重探) */
82
+ ttlMs?: number
83
+ }
84
+
85
+ /** 1.14-S4(K-POOL-1 / D-42):模型池登记。与 llm.baseUrl/model/adapterId 并存时池装配压过(每次路由决策落链可解释);llm.maxTokens/allowEnvFallback/compaction 仍生效。无 modelPool 配置 = 行为与 1.13 逐字节一致(兼容验收线) */
86
+ export interface ModelPoolConfig {
87
+ endpoints: ModelPoolEndpointSpec[]
88
+ probe?: ModelPoolProbeConfig
89
+ /** 候选请求尝试上限(含首选;缺省 = 端点数) */
90
+ maxAttempts?: number
44
91
  }
45
92
 
46
93
  export interface McpServerSpec {
@@ -59,6 +106,8 @@ export interface CarConfig {
59
106
  sandbox?: SandboxSigConfig
60
107
  /** 1.4-S3(D-16):模型接入配置键位空间;凭据禁入(CR-05)——只放端点/模型名/开关 */
61
108
  llm?: LlmConfig
109
+ /** 1.14-S4(K-POOL-1 / D-42):模型池登记(多端点 fallback;与 llm.* 并存时池压过,路由决策落链) */
110
+ modelPool?: ModelPoolConfig
62
111
  /** 1.4-S7(D-18):MCP server 登记面(--mcp <id> 显式启用) */
63
112
  mcp?: { servers: Record<string, McpServerSpec> }
64
113
  }
@@ -82,6 +131,66 @@ const nonEmptyTrimmed = () =>
82
131
  const stringMap = () =>
83
132
  z.record(z.string(), z.string({ invalid_type_error: '必须为字符串' }), { invalid_type_error: '必须为字符串映射', required_error: '必须为字符串映射' })
84
133
 
134
+ // 1.14-S4(K-POOL-1)辅助:正整数 / 非负整数
135
+ const positiveInt = () =>
136
+ z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
137
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
138
+ const nonNegativeInt = () =>
139
+ z.number({ invalid_type_error: '必须为非负整数', required_error: '必须为非负整数' })
140
+ .refine(v => Number.isFinite(v) && Number.isInteger(v) && v >= 0, { message: '必须为非负整数' })
141
+
142
+ // 明文凭据键黑名单(1.14-S4 / CR-05):专用文案先于未知键检查(首个 issue 渲染)
143
+ const PLAINTEXT_CRED_KEYS = ['key', 'apiKey', 'api_key', 'apikey', 'token', 'password']
144
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
145
+ const POOL_ENDPOINT_KEYS = new Set(['id', 'baseUrl', 'model', 'adapterId', 'keyEnv', 'priority', 'weight', 'maxTokens'])
146
+
147
+ /**
148
+ * 池端点 schema(1.14-S4):passthrough + superRefine 显式拒绝(等价 strict,但明文凭据键
149
+ * 给专用文案——CR-05 红线「明文 key 显式拒绝」必须可读可引导,不能只报「未知键」)。
150
+ */
151
+ const poolEndpointSchema = z.object({
152
+ id: nonEmptyTrimmed().optional(),
153
+ baseUrl: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }),
154
+ model: nonEmptyTrimmed(),
155
+ adapterId: z.enum(['openai-compat', 'anthropic'], { errorMap: () => ({ message: '必须为 openai-compat 或 anthropic(config 可装配集 = 内置两族)' }) }).optional(),
156
+ keyEnv: nonEmptyTrimmed().refine(v => ENV_NAME_RE.test(v), { message: '必须为环境变量名(明文凭据禁入——凭据仅收 keyEnv 引用,CR-05)' }).optional(),
157
+ priority: positiveInt().optional(),
158
+ weight: nonNegativeInt().optional(),
159
+ maxTokens: positiveInt().optional(),
160
+ }).passthrough().superRefine((obj, ctx) => {
161
+ for (const k of PLAINTEXT_CRED_KEYS) {
162
+ if (k in obj) {
163
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [k], message: `明文凭据禁入——凭据仅收 keyEnv(环境变量名引用),明文键 "${k}" 一律拒绝` })
164
+ return
165
+ }
166
+ }
167
+ for (const k of Object.keys(obj)) {
168
+ if (!POOL_ENDPOINT_KEYS.has(k)) {
169
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [k], message: '未登记键(登记键位:id/baseUrl/model/adapterId/keyEnv/priority/weight/maxTokens)' })
170
+ return
171
+ }
172
+ }
173
+ })
174
+
175
+ const modelPoolSchema = z.object({
176
+ endpoints: z.array(poolEndpointSchema, { invalid_type_error: '必须为端点数组', required_error: '必须为端点数组' })
177
+ .min(1, { message: '至少登记一个端点(池空 = CAR-E-CONFIG;无池需求请移除 modelPool 键,行为与 1.13 一致)' }),
178
+ probe: z.object({
179
+ enabled: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
180
+ timeoutMs: positiveInt().optional(),
181
+ ttlMs: nonNegativeInt().optional(),
182
+ }).strict().optional(),
183
+ maxAttempts: positiveInt().optional(),
184
+ }).strict().superRefine((obj, ctx) => {
185
+ // 端点 id 唯一(缺省派生同口径)——routing/decide 落链定位歧义禁入
186
+ const ids = new Set<string>()
187
+ for (const [i, ep] of (obj.endpoints ?? []).entries()) {
188
+ const id = ep.id ?? `ep-${i + 1}`
189
+ if (ids.has(id)) ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['endpoints', i], message: `端点 id "${id}" 重复(落链定位歧义,禁静默)` })
190
+ ids.add(id)
191
+ }
192
+ })
193
+
85
194
  const mcpServerSchema = z.object({
86
195
  command: nonEmptyTrimmed().optional(),
87
196
  url: nonEmptyTrimmed().refine(v => /^https?:\/\//.test(v), { message: '必须为 http(s) URL' }).optional(),
@@ -118,14 +227,24 @@ const rawSchema = z.object({
118
227
  .refine(v => Number.isFinite(v) && Number.isInteger(v) && v > 0, { message: '必须为正整数' })
119
228
  .optional(),
120
229
  allowEnvFallback: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
230
+ // 1.14-S2(K-MP-2 / D-40):历史压缩策略(阈值事实计数,非 token 估算)
231
+ compaction: z.object({
232
+ enabled: z.boolean({ invalid_type_error: '必须为布尔', required_error: '必须为布尔' }).optional(),
233
+ thresholdEvents: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
234
+ .refine(v => Number.isInteger(v) && v > 0, { message: '必须为正整数' }).optional(),
235
+ protectLastTurns: z.number({ invalid_type_error: '必须为正整数', required_error: '必须为正整数' })
236
+ .refine(v => Number.isInteger(v) && v > 0, { message: '必须为正整数' }).optional(),
237
+ }).strict().optional(),
121
238
  }).strict().optional(),
239
+ // 1.14-S4(K-POOL-1 / D-42):模型池登记(多端点 fallback + health + routing/decide 落链)
240
+ modelPool: modelPoolSchema.optional(),
122
241
  mcp: z.object({
123
242
  servers: z.record(z.string(), mcpServerSchema, { invalid_type_error: '必须为对象(serverId → 启动规格)' }),
124
243
  }).strict().optional(),
125
244
  }, { invalid_type_error: '配置顶层必须为 JSON 对象' }).strict()
126
245
 
127
246
  /** 未登记键提示(渲染层拼 path)——与系统设计键位空间同步 */
128
- const KEY_SPACES = '登记键位:sandbox.* / llm.* / mcp.servers.*'
247
+ const KEY_SPACES = '登记键位:sandbox.* / llm.* / llm.modelPool.* / mcp.servers.*'
129
248
 
130
249
  /** zod issue → CAR-E-CONFIG 渲染(首个 issue;unrecognized_keys 按键路径展开) */
131
250
  function renderIssue(error: z.ZodError): string {
@@ -161,6 +280,13 @@ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
161
280
  if (d.llm.adapterId !== undefined) llm.adapterId = d.llm.adapterId
162
281
  if (d.llm.maxTokens !== undefined) llm.maxTokens = d.llm.maxTokens
163
282
  if (d.llm.allowEnvFallback !== undefined) llm.allowEnvFallback = d.llm.allowEnvFallback
283
+ if (d.llm.compaction) {
284
+ const cp: CompactionConfig = {}
285
+ if (d.llm.compaction.enabled !== undefined) cp.enabled = d.llm.compaction.enabled
286
+ if (d.llm.compaction.thresholdEvents !== undefined) cp.thresholdEvents = d.llm.compaction.thresholdEvents
287
+ if (d.llm.compaction.protectLastTurns !== undefined) cp.protectLastTurns = d.llm.compaction.protectLastTurns
288
+ llm.compaction = cp
289
+ }
164
290
  out.llm = llm
165
291
  }
166
292
  if (d.mcp) {
@@ -176,6 +302,14 @@ function parseCarConfig(raw: unknown): { config: CarConfig; error?: string } {
176
302
  }
177
303
  out.mcp = { servers }
178
304
  }
305
+ if (d.modelPool) {
306
+ const mp = d.modelPool as { endpoints: ModelPoolEndpointSpec[]; probe?: ModelPoolProbeConfig; maxAttempts?: number }
307
+ out.modelPool = {
308
+ endpoints: mp.endpoints.map(ep => ({ ...ep })),
309
+ ...(mp.probe ? { probe: { ...mp.probe } } : {}),
310
+ ...(mp.maxAttempts !== undefined ? { maxAttempts: mp.maxAttempts } : {}),
311
+ }
312
+ }
179
313
  return { config: out }
180
314
  }
181
315
 
@@ -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: {
@@ -37,6 +37,12 @@ export interface ChatStepOptions {
37
37
  adapterId?: string
38
38
  /** 1.4-S4(D-19):turn 取消信号——chunk 间隙与流结束后(finish 检查前)检查,命中抛 TurnAborted */
39
39
  signal?: { aborted: boolean }
40
+ /**
41
+ * 1.14.1(K-MP-1 消费面 / D-39):思考参数透传——chatStep 是宿主 model 回调的唯一路径,
42
+ * 本形参补齐后 reasoning 才经 chatStep 可达(off / 缺席 = 不下发该字段,字节面零痕迹);
43
+ * 模型元数据思考档位判断在宿主侧(models:meta),内核防线 = 未知档位 fail-visible。
44
+ */
45
+ reasoning?: { effort: 'off' | 'low' | 'medium' | 'high' }
40
46
  }
41
47
 
42
48
  /** ModelStep 加法扩展:脱敏计数留痕(S15 secretsRedacted 同名口径) */
@@ -78,6 +84,8 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
78
84
  tools,
79
85
  ...(maxTokens != null ? { maxTokens } : {}),
80
86
  ...(adapterId ? { adapterId } : {}),
87
+ // 1.14.1(K-MP-1 消费面):reasoning 透传(off/缺席 = 不下发)
88
+ ...(opts.reasoning ? { reasoning: opts.reasoning } : {}),
81
89
  ...(opts.signal ? { signal: opts.signal } : {}),
82
90
  ...(opts.log.sessionId ? { metadata: { sessionId: opts.log.sessionId, turnId, traceId: `${opts.log.sessionId}:${turnId}` } } : {}),
83
91
  })
@@ -117,6 +125,8 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
117
125
  output: usage.outputTokens,
118
126
  cacheRead: usage.cacheReadTokens,
119
127
  cacheCreation: usage.cacheCreationTokens,
128
+ // 1.14-S3(K-MP-2b / D-41):分段事实随事件落链——无分段 = 不带键(禁估算)
129
+ ...(usage.segments ? { segments: usage.segments } : {}),
120
130
  providerRaw: usage.providerRaw,
121
131
  })
122
132
  }
@@ -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]) {