@lqc123qwe/car-runtime 1.0.0 → 1.2.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,304 @@
1
+ /**
2
+ * M8 · LLM 适配面(chat / registerLlmAdapter ——《系统设计》§3.2.M8.2 模型与适配行 / SQ-07 / O-11)
3
+ *
4
+ * 口径:
5
+ * - 适配器为 ACL(C-02):统一 LlmAdapter 签名消解 Provider 差异;注册经 ctx.effect 可逆
6
+ * (disposeRuntime 回卷后可重注册——注册即逆变换,POC-2 红线同源);重复 id 显式报错禁静默覆盖;
7
+ * - finishReason 不可变透传(ADR-001 / SR-17):SDK 边界流包装做防御性断言——首个
8
+ * finishReason 定格,冲突 = 适配器契约违规显式抛错(改写/吞没均禁止,缺失触发 AL-05 口径);
9
+ * - 重试基线(§3.5.4):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;**首块前失败才可重试**
10
+ * (已消费的 chunk 不可重放——重试不产生额外副作用);重试耗尽 = CarM8Error B080001
11
+ * (BD-04 error 收口由消费方 runTurn 落 turnEnd);流中途失败 = finishReason 'error' chunk;
12
+ * - 超时基线:总 120s / 首字节 30s(均可注入缩短供测试);TLS 强制(https-only);
13
+ * - declaredSideEffect 不出站(权限面只进权限门,不进模型请求体)。
14
+ */
15
+ import { CarM8Error, providerUnreachable } from './errors.ts'
16
+ import { CredentialService } from './credentials.ts'
17
+ import type { Disposable, LlmAdapter, LlmChunk, LlmRequest, ToolCallDelta } from './types.ts'
18
+ import type { PluginContext } from '../kernel/context.ts'
19
+
20
+ // ==================== 适配器注册表(registerLlmAdapter) ====================
21
+
22
+ export class AdapterRegistry {
23
+ #adapters = new Map<string, LlmAdapter>()
24
+ #defaultId: string | null = null
25
+
26
+ /** 注册(幂等语义:同 id 重复注册显式报错——冲突链定位风格) */
27
+ register(adapter: LlmAdapter, opts: { default?: boolean } = {}): Disposable {
28
+ if (this.#adapters.has(adapter.id)) {
29
+ throw new Error(`CAR-E-LLM-DUP: LLM adapter "${adapter.id}" already registered (duplicate registration is blocked)`)
30
+ }
31
+ this.#adapters.set(adapter.id, adapter)
32
+ if (opts.default || this.#defaultId === null) this.#defaultId = adapter.id
33
+ let disposed = false
34
+ return {
35
+ dispose: () => {
36
+ if (disposed) return // 幂等回卷(二次 dispose 静默收敛,登记口径)
37
+ disposed = true
38
+ this.#adapters.delete(adapter.id)
39
+ if (this.#defaultId === adapter.id) this.#defaultId = this.#adapters.keys().next().value ?? null
40
+ },
41
+ }
42
+ }
43
+
44
+ /** 解析:显式 id > 默认适配器;无可用适配器显式报错(禁静默降级) */
45
+ get(id?: string): LlmAdapter {
46
+ const target = id ?? this.#defaultId
47
+ if (!target || !this.#adapters.has(target)) {
48
+ const available = [...this.#adapters.keys()].join(', ') || '无'
49
+ throw new Error(`CAR-E-LLM-NOADAPTER: LLM adapter "${target ?? '<default>'}" not registered(在册:${available})`)
50
+ }
51
+ return this.#adapters.get(target)!
52
+ }
53
+
54
+ list(): Array<{ id: string; isDefault: boolean }> {
55
+ return [...this.#adapters.keys()].map(id => ({ id, isDefault: id === this.#defaultId }))
56
+ }
57
+ }
58
+
59
+ // ==================== finishReason 不可变守卫(SDK 边界) ====================
60
+
61
+ /** 首个 finishReason 定格;适配器后续给出不同值 = 契约违规显式抛错(不改写不吞没) */
62
+ export async function* withFinishReasonGuard(stream: AsyncIterable<LlmChunk>): AsyncIterable<LlmChunk> {
63
+ let settled: LlmChunk['finishReason'] | undefined
64
+ for await (const chunk of stream) {
65
+ if (chunk.finishReason !== undefined) {
66
+ if (settled === undefined) settled = chunk.finishReason
67
+ else if (settled !== chunk.finishReason) {
68
+ throw new Error(`CAR-E-LLM-FINISH: finishReason 已定格为 "${settled}",适配器试图给出 "${chunk.finishReason}"(不可变透传契约违规)`)
69
+ }
70
+ }
71
+ yield chunk
72
+ }
73
+ }
74
+
75
+ // ==================== RuntimeCore(M8 SDK 面) ====================
76
+
77
+ export class RuntimeCore {
78
+ readonly registry = new AdapterRegistry()
79
+ readonly credentials: CredentialService
80
+ #ctx: Pick<PluginContext, 'provide' | 'effect'> | null = null
81
+
82
+ constructor(credentials: CredentialService = new CredentialService()) {
83
+ this.credentials = credentials
84
+ }
85
+
86
+ /** 绑定插件作用域:服务注册 + 适配器注册进入 Effect 可逆通道(宿主传 Context——根作用域 effect 同纪律) */
87
+ bindContext(ctx: Pick<PluginContext, 'provide' | 'effect'>): void {
88
+ this.#ctx = ctx
89
+ ctx.provide('runtime-core', this)
90
+ }
91
+
92
+ /** §3.2.M8.2:registerLlmAdapter(adapter): Disposable——Effect 可逆(卸载回卷后可重注册) */
93
+ registerLlmAdapter(adapter: LlmAdapter, opts: { default?: boolean } = {}): Disposable {
94
+ if (!this.#ctx) return this.registry.register(adapter, opts)
95
+ let handle: Disposable | null = null
96
+ this.#ctx.effect(() => {
97
+ handle = this.registry.register(adapter, opts)
98
+ return () => handle?.dispose()
99
+ }, `llm-adapter:${adapter.id}`)
100
+ return { dispose: () => handle?.dispose() }
101
+ }
102
+
103
+ /** §3.2.M8.2:chat(req): AsyncIterable<LlmChunk>——统一入口 + finishReason 守卫 */
104
+ chat(req: LlmRequest): AsyncIterable<LlmChunk> {
105
+ const adapter = this.registry.get(req.adapterId)
106
+ return withFinishReasonGuard(adapter.chat(req))
107
+ }
108
+ }
109
+
110
+ // ==================== 内置 openai-compat 适配器(流式 SSE) ====================
111
+
112
+ export interface OpenAICompatOptions {
113
+ id?: string
114
+ baseUrl: string
115
+ /** 凭据服务与 provider 名(Bearer token 来源) */
116
+ credentials?: CredentialService
117
+ provider?: string
118
+ fetchImpl?: typeof fetch
119
+ /** §3.5.4 基线:总 120s / 首字节 30s / 2 次退避 1s-2s(测试可注入缩短) */
120
+ timeoutMs?: number
121
+ firstByteMs?: number
122
+ retries?: number
123
+ backoffMs?: number[]
124
+ /** 测试注入睡眠(退避可观测;缺省真实定时器) */
125
+ sleep?: (ms: number) => Promise<void>
126
+ }
127
+
128
+ const FINISH_MAP: Record<string, LlmChunk['finishReason']> = {
129
+ stop: 'stop',
130
+ length: 'length',
131
+ tool_calls: 'toolUse',
132
+ function_call: 'toolUse',
133
+ content_filter: 'aborted',
134
+ }
135
+
136
+ class Retryable extends Error {}
137
+
138
+ /** 明文 SSE → LlmChunk 流解析(增量容错:半行驻留到下一 chunk) */
139
+ async function* parseSseStream(body: AsyncIterable<Uint8Array>): AsyncIterable<LlmChunk> {
140
+ const decoder = new TextDecoder()
141
+ let buf = ''
142
+ for await (const bytes of body) {
143
+ buf += decoder.decode(bytes, { stream: true })
144
+ let nl: number
145
+ while ((nl = buf.indexOf('\n')) >= 0) {
146
+ const line = buf.slice(0, nl).replace(/\r$/, '')
147
+ buf = buf.slice(nl + 1)
148
+ if (!line.startsWith('data:')) continue
149
+ const data = line.slice(5).trim()
150
+ if (data === '[DONE]') return
151
+ if (!data) continue
152
+ let evt: any
153
+ try { evt = JSON.parse(data) } catch { continue } // 心跳/注释行容错(显式跳过非 JSON data)
154
+ const choice = evt.choices?.[0]
155
+ if (!choice) continue
156
+ const delta = choice.delta ?? {}
157
+ const raw = choice.finish_reason
158
+ const finish: LlmChunk['finishReason'] | undefined = raw == null ? undefined : (() => {
159
+ const mapped = FINISH_MAP[String(raw)]
160
+ return mapped
161
+ ? mapped
162
+ : 'error' // 未映射信号 fail-visible(禁静默改写),raw 附在 error.message
163
+ })()
164
+ const toolCallDeltas: ToolCallDelta[] = Array.isArray(delta.tool_calls)
165
+ ? delta.tool_calls.map((tc: any) => ({
166
+ index: tc.index,
167
+ id: tc.id,
168
+ name: tc.function?.name ?? tc.name,
169
+ argumentsDelta: tc.function?.arguments,
170
+ }))
171
+ : []
172
+ const text = typeof delta.content === 'string' && delta.content.length ? delta.content : undefined
173
+ // LlmChunk 单 toolCallDelta 口径:同事件多条 tool_calls 顺序展开为多 chunk
174
+ if (!text && !toolCallDeltas.length && finish === undefined) continue
175
+ if (toolCallDeltas.length === 0) {
176
+ yield {
177
+ ...(text !== undefined ? { delta: text } : {}),
178
+ ...(finish !== undefined
179
+ ? { finishReason: finish, ...(finish === 'error' ? { error: { code: 'B080001', message: `未映射的 finish_reason "${raw}"` } } : {}) }
180
+ : {}),
181
+ }
182
+ } else {
183
+ for (let i = 0; i < toolCallDeltas.length; i++) {
184
+ const last = i === toolCallDeltas.length - 1
185
+ yield {
186
+ ...(i === 0 && text !== undefined ? { delta: text } : {}),
187
+ toolCallDelta: toolCallDeltas[i],
188
+ ...(last && finish !== undefined
189
+ ? { finishReason: finish, ...(finish === 'error' ? { error: { code: 'B080001', message: `未映射的 finish_reason "${raw}"` } } : {}) }
190
+ : {}),
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter & { id: string } {
199
+ const id = opts.id ?? 'openai-compat'
200
+ const baseUrl = opts.baseUrl.replace(/\/+$/, '')
201
+ if (!baseUrl.startsWith('https://')) {
202
+ throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4;本地明文部署请经 TLS 代理或自建适配器)`)
203
+ }
204
+ const url = `${baseUrl}/chat/completions`
205
+ const fetchImpl = opts.fetchImpl ?? fetch
206
+ const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
207
+ const timeoutMs = opts.timeoutMs ?? 120_000
208
+ const firstByteMs = opts.firstByteMs ?? 30_000
209
+ const retries = opts.retries ?? 2
210
+ const backoffMs = opts.backoffMs ?? [1_000, 2_000]
211
+
212
+ const toOpenAI = (req: LlmRequest, apiKey: string) => ({
213
+ headers: {
214
+ 'content-type': 'application/json',
215
+ authorization: `Bearer ${apiKey}`,
216
+ },
217
+ body: JSON.stringify({
218
+ model: req.model,
219
+ stream: true,
220
+ ...(req.maxTokens != null ? { max_tokens: req.maxTokens } : {}),
221
+ messages: req.messages.map(m => {
222
+ if (m.role === 'toolResult') {
223
+ const p = m.content as { id?: string; result?: unknown; error?: string }
224
+ return { role: 'tool', tool_call_id: p?.id, content: p?.error ?? JSON.stringify(p?.result ?? null) }
225
+ }
226
+ if (m.role === 'assistant' && m.content != null && typeof m.content === 'object' && 'toolCall' in (m.content as object)) {
227
+ const tc = (m.content as { toolCall: { id: string; tool: string; args: unknown } }).toolCall
228
+ return {
229
+ role: 'assistant',
230
+ content: null,
231
+ tool_calls: [{ id: tc.id, type: 'function', function: { name: tc.tool, arguments: JSON.stringify(tc.args ?? {}) } }],
232
+ }
233
+ }
234
+ return { role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }
235
+ }),
236
+ // declaredSideEffect 不出站:权限面只进权限门(§3.2.M8.3 字段表约束方向)
237
+ ...(req.tools.length ? { tools: req.tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } })) } : {}),
238
+ }),
239
+ })
240
+
241
+ return {
242
+ id,
243
+ async *chat(req: LlmRequest): AsyncIterable<LlmChunk> {
244
+ // 凭据门:resolve → reveal(SQ-07 凭据解析;A080001 在此抛出——首块前,runTurn 可 error 收口)
245
+ let apiKey = ''
246
+ if (opts.credentials) {
247
+ const ref = opts.credentials.resolve(opts.provider ?? id, {})
248
+ apiKey = opts.credentials.reveal(ref, {})
249
+ }
250
+ const payload = toOpenAI(req, apiKey)
251
+
252
+ for (let attempt = 0; ; attempt++) {
253
+ const controller = new AbortController()
254
+ const started = Date.now()
255
+ let firstByteSeen = false
256
+ const firstByteTimer = setTimeout(() => controller.abort(), firstByteMs)
257
+ const overallTimer = setTimeout(() => controller.abort(), timeoutMs)
258
+ try {
259
+ const res = await fetchImpl(url, { ...payload, signal: controller.signal })
260
+ if (res.status === 429 || res.status >= 500) throw new Retryable(`HTTP ${res.status}`)
261
+ if (!res.ok) {
262
+ const text = await res.text().catch(() => '')
263
+ // 4xx 业务错不重试(§3.5.4);错误信息不含凭据(Bearer 值不进错误路径)
264
+ yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } }
265
+ return
266
+ }
267
+ if (!res.body) throw new Retryable('响应无 body 流')
268
+ let sawFinish = false
269
+ for await (const chunk of parseSseStream(res.body)) {
270
+ if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
271
+ if (chunk.finishReason !== undefined) sawFinish = true
272
+ yield chunk
273
+ }
274
+ // 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
275
+ // 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1:无条件 error chunk
276
+ // 会污染成功流并触发 finishReason 守卫冲突;成功流零 error chunk 由 s25 断言钉死)
277
+ if (!sawFinish) {
278
+ yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } }
279
+ }
280
+ return
281
+ } catch (e) {
282
+ const beforeFirstByte = !firstByteSeen
283
+ clearTimeout(firstByteTimer)
284
+ // 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何
285
+ // chunk,请求体可重放,重试不产生额外副作用;流中途失败不可重放(chunk 已消费)
286
+ if (beforeFirstByte && attempt < retries) {
287
+ await sleep(backoffMs[Math.min(attempt, backoffMs.length - 1)]!)
288
+ continue
289
+ }
290
+ if (beforeFirstByte) {
291
+ // 重试耗尽 → B080001(BD-04:消费方 runTurn 以 error 收口)
292
+ throw providerUnreachable(`${(e as Error).name}: ${(e as Error).message}(尝试 ${attempt + 1}/${retries + 1},${Date.now() - started}ms)`)
293
+ }
294
+ // 流中途失败:已消费 chunk 不可重放 → error chunk 收口
295
+ yield { finishReason: 'error', error: { code: 'B080001', message: `流中途失败:${(e as Error).message}` } }
296
+ return
297
+ } finally {
298
+ clearTimeout(firstByteTimer)
299
+ clearTimeout(overallTimer)
300
+ }
301
+ }
302
+ },
303
+ }
304
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * M8 · 脱敏钩子(redact ——《系统设计》§3.2.M8.2 脱敏行 / SQ-07 输出敏感扫描 / O-14)
3
+ *
4
+ * 口径:
5
+ * - 检测器单一权威:本层只做 M8 面(RedactionResult 形态 + 流式 hold-back),
6
+ * 模式库/三层检测/遮蔽口径全部委托 F12 scanSecrets(security/secrets.ts)——
7
+ * 双实现必然漂移,S15 已确立「回填前强制 redact」先例;
8
+ * - 遮蔽口径(§3.2.M8.3 字段表):API Key 形如 sk-****(保留首尾 ≤4 字符)= F12 maskToken;
9
+ * - 流式扫描(StreamRedactor):delta 直发会造成跨 chunk 命中逃逸——hold-back 窗口
10
+ * 「从最早疑似锚点起驻留」,命中补齐即遮蔽放行,finishReason 收口时 flush 全量扫描;
11
+ * 结构标记类模式(GCP service_account 等)的匹配起点先于锚点,流式模式不保证完整遮蔽
12
+ * (登记为流式已知边界;非流式 redact() 为权威口径)。
13
+ */
14
+ import { scanSecrets, type ScanHit } from '../security/secrets.ts'
15
+
16
+ export interface RedactionResult {
17
+ /** 脱敏后文本(§3.2.M8.3 字段表口径:RedactionResult.redacted) */
18
+ redacted: string
19
+ /** 命中遮蔽次数 */
20
+ redactedCount: number
21
+ /** 命中明细(审计留痕用;不含原文) */
22
+ hits: Array<Pick<ScanHit, 'category' | 'name' | 'start' | 'end' | 'masked' | 'score'>>
23
+ }
24
+
25
+ /** 非流式脱敏钩子(§3.2.M8.2:redact(text): RedactionResult,天然幂等) */
26
+ export function redact(text: string): RedactionResult {
27
+ const { hits } = scanSecrets(text)
28
+ let out = ''
29
+ let cursor = 0
30
+ for (const h of hits) {
31
+ out += text.slice(cursor, h.start) + h.masked
32
+ cursor = h.end
33
+ }
34
+ out += text.slice(cursor)
35
+ return { redacted: out, redactedCount: hits.length, hits }
36
+ }
37
+
38
+ /**
39
+ * 疑似锚点:所有模式匹配串的起始字面量(大小写不敏感)。
40
+ * 命中检测必然先经过锚点——锚点之后的字节才需要驻留等待命中补齐。
41
+ */
42
+ const ANCHOR_RE = /(sk[-_]|gh[pousr]_|xox|akia|rk_|npm_|-----begin|postgres|mysql|mongodb|redis|jdbc:|aws|service_account|accountkey|eyJ|authorization|bearer|api[_-]?key|client[_-]?secret|access[_-]?token|password)/i
43
+
44
+ /** 无锚点时的尾部驻留长度(≥ 最长锚点 15,防锚点跨 chunk 拼合逃逸) */
45
+ const TRAILING_HOLD = 16
46
+
47
+ /**
48
+ * 流式脱敏器:push() 返回「当前可安全放行」的文本(已遮蔽),flush() 收口全量扫描。
49
+ * 语义:不泄漏(任何已放行字节不可能成为命中的一部分)、不误遮(锚点未补齐成命中时
50
+ * 原文放行)、粒度换正确性(疑似锚点驻留直到命中补齐或 flush——长行含假锚点时
51
+ * 流式粒度退化到收口时点,登记为已接受折衷)。
52
+ */
53
+ export class StreamRedactor {
54
+ #pending = ''
55
+
56
+ /** 追加 delta,返回可安全放行的文本(可能为空串) */
57
+ push(text: string): string {
58
+ this.#pending += text
59
+ let out = ''
60
+ // 1) 命中补齐:从前往后逐个遮蔽(遮蔽后重扫——遮蔽串不会再命中)
61
+ for (;;) {
62
+ const { hits } = scanSecrets(this.#pending)
63
+ const h = hits[0]
64
+ if (!h) break
65
+ out += this.#pending.slice(0, h.start) + h.masked
66
+ this.#pending = this.#pending.slice(h.end)
67
+ }
68
+ // 2) 无完整命中:从最早疑似锚点起驻留
69
+ const m = ANCHOR_RE.exec(this.#pending)
70
+ if (m && m.index !== undefined) {
71
+ out += this.#pending.slice(0, m.index)
72
+ this.#pending = this.#pending.slice(m.index)
73
+ return out
74
+ }
75
+ // 3) 无锚点:放行除尾部驻留窗外的全部(尾窗防锚点跨 chunk 拼合)
76
+ if (this.#pending.length > TRAILING_HOLD) {
77
+ out += this.#pending.slice(0, this.#pending.length - TRAILING_HOLD)
78
+ this.#pending = this.#pending.slice(-TRAILING_HOLD)
79
+ }
80
+ return out
81
+ }
82
+
83
+ /** 收口(finishReason 时点):全量扫描遮蔽剩余驻留 */
84
+ flush(): string {
85
+ const r = redact(this.#pending)
86
+ this.#pending = ''
87
+ return r.redacted
88
+ }
89
+
90
+ /** 已遮蔽计数口径(测试/审计观察面):flush 前的累计放行不含命中原文 */
91
+ get pendingLength(): number {
92
+ return this.#pending.length
93
+ }
94
+ }