@lqc123qwe/car-runtime 1.12.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.
- package/README.md +10 -1
- package/dist/SHA256SUMS +1 -1
- package/dist/authz/authz.d.ts +9 -0
- package/dist/authz/authz.js +15 -0
- package/dist/car-runtime-1.14.0.tgz +0 -0
- package/dist/car-runtime-1.14.0.tgz.sig.bundle +1 -0
- package/dist/cli.js +48 -16
- package/dist/load/config.d.ts +45 -0
- package/dist/load/config.js +84 -1
- package/dist/load/pool.d.ts +97 -0
- package/dist/load/pool.js +261 -0
- package/dist/loop/stop.d.ts +6 -0
- package/dist/loop/stop.js +14 -3
- package/dist/release-audit.jsonl +4 -4
- package/dist/runtime-core/chatStep.js +2 -0
- package/dist/runtime-core/credentials.d.ts +2 -0
- package/dist/runtime-core/credentials.js +2 -1
- package/dist/runtime-core/llm.d.ts +6 -0
- package/dist/runtime-core/llm.js +54 -3
- package/dist/runtime-core/types.d.ts +15 -0
- package/dist/sandbox/sandbox.d.ts +7 -1
- package/dist/sandbox/sandbox.js +10 -4
- package/dist/sbom.json +24 -14
- package/dist/seam.d.ts +15 -1
- package/dist/seam.js +17 -1
- package/dist/session/compact.d.ts +57 -0
- package/dist/session/compact.js +128 -0
- package/dist/session/log.d.ts +5 -1
- package/dist/session/log.js +22 -2
- package/package.json +1 -1
- package/src/authz/authz.ts +16 -0
- package/src/cli.ts +48 -17
- package/src/load/config.ts +135 -1
- package/src/load/pool.ts +339 -0
- package/src/loop/stop.ts +19 -4
- package/src/runtime-core/chatStep.ts +2 -0
- package/src/runtime-core/credentials.ts +4 -1
- package/src/runtime-core/llm.ts +59 -3
- package/src/runtime-core/types.ts +11 -0
- package/src/sandbox/sandbox.ts +20 -5
- package/src/seam.ts +17 -1
- package/src/session/compact.ts +168 -0
- package/src/session/log.ts +19 -3
- package/dist/car-runtime-1.12.0.tgz +0 -0
- package/dist/car-runtime-1.12.0.tgz.sig.bundle +0 -1
package/src/load/config.ts
CHANGED
|
@@ -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
|
|
package/src/load/pool.ts
ADDED
|
@@ -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
|
@@ -48,6 +48,12 @@ export interface TurnPreset {
|
|
|
48
48
|
mode?: 'readonly' | 'confirm' | 'full'
|
|
49
49
|
/** 确认模式审批回调:返回 true 放行 */
|
|
50
50
|
authorize?: (call: ToolCall) => Promise<boolean>
|
|
51
|
+
/**
|
|
52
|
+
* 1.13.0(K-FS-1):authorizeAll=true 将 confirm 模式的授权门扩展到 readonly 类工具
|
|
53
|
+
* (文件安全规则轴的读取面——读取的询问/拒绝规则可达);缺省 false = 既有行为零变化
|
|
54
|
+
* (readonly 模式契约不变:写类一律拒、只读类照常放行;full 整体旁路)。
|
|
55
|
+
*/
|
|
56
|
+
authorizeAll?: boolean
|
|
51
57
|
maxSteps?: number
|
|
52
58
|
/** F13:OR/AND 收口可配置(默认 any/all = M1 行为等价) */
|
|
53
59
|
aggregate?: { concludesTurn?: 'any' | 'all'; terminate?: 'any' | 'all' }
|
|
@@ -66,12 +72,13 @@ export class TurnAborted extends Error {
|
|
|
66
72
|
* 在 JSONL,聚合由事实行求和而非运行时状态;字段非数值容错跳过不吞计数)。无 usage 事件
|
|
67
73
|
* 返回 undefined → turnEnd meta 不带 usage 键(缺省=未采集,旧格式向后兼容)。
|
|
68
74
|
*/
|
|
69
|
-
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 {
|
|
70
76
|
let input = 0
|
|
71
77
|
let output = 0
|
|
72
78
|
let cacheRead = 0
|
|
73
79
|
let cacheCreation = 0
|
|
74
80
|
let requests = 0
|
|
81
|
+
let reasoning: number | undefined
|
|
75
82
|
for (const e of log.events) {
|
|
76
83
|
if (e.kind !== 'usage' || e.turnId !== turnId) continue
|
|
77
84
|
const p = (e.payload ?? {}) as Record<string, unknown>
|
|
@@ -79,9 +86,14 @@ function turnUsageFrom(log: SessionLog, turnId: string): { input: number; output
|
|
|
79
86
|
if (typeof p.output === 'number') output += p.output
|
|
80
87
|
if (typeof p.cacheRead === 'number') cacheRead += p.cacheRead
|
|
81
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
|
|
82
93
|
requests++
|
|
83
94
|
}
|
|
84
|
-
|
|
95
|
+
if (!requests) return undefined
|
|
96
|
+
return { input, output, cacheRead, cacheCreation, requests, ...(reasoning !== undefined ? { segments: { reasoningTokens: reasoning } } : {}) }
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
export async function runTurn(opts: {
|
|
@@ -124,8 +136,11 @@ export async function runTurn(opts: {
|
|
|
124
136
|
log.append('plugin', 'toolResult', turnId, { id: call.id, error: `unknown tool "${call.tool}"` })
|
|
125
137
|
continue
|
|
126
138
|
}
|
|
127
|
-
// 权限门(F4 集成点):write 类在 readonly/confirm
|
|
128
|
-
|
|
139
|
+
// 权限门(F4 集成点):write 类在 readonly/confirm 模式需放行;1.13.0(K-FS-1):
|
|
140
|
+
// authorizeAll=true 且 confirm 时 readonly 类同门(规则轴读取面),readonly 模式契约不变
|
|
141
|
+
const gated = preset.mode !== 'full' &&
|
|
142
|
+
(def.declaredSideEffect === 'write' || (preset.authorizeAll === true && preset.mode === 'confirm'))
|
|
143
|
+
if (gated) {
|
|
129
144
|
const granted = preset.mode === 'confirm' ? await (preset.authorize?.(call) ?? Promise.resolve(false)) : false
|
|
130
145
|
log.append('runtime', 'toolResult', turnId, { id: call.id, error: 'authorization-denied', granted, mode: preset.mode })
|
|
131
146
|
if (!granted) { denies++; continue }
|
|
@@ -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
|
-
|
|
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]) {
|