@lqc123qwe/car-runtime 1.0.0 → 1.1.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,60 @@
1
+ /**
2
+ * 1.1-S1 · plugin-sign 签名原语(keygen / sign 纯函数;verify 复用 sigGate.verifyPluginFile)
3
+ *
4
+ * 口径(与 M2-S6 verifier / M5-S28 sigGate 签验同源,逐字对齐签名强制启用指南 §2):
5
+ * - 被签物 = 插件入口 .ts 文件裸字节 sha256 的 hex 串;被签消息 = hex 字符串的 UTF-8 字节
6
+ * (sigGate.ts 头注释同源;哈希对裸字节求——G-11 教训:禁止对编码文本求哈希)
7
+ * - 私钥 = ed25519 PKCS8 DER 文件(<前缀>.priv);公钥 = SPKI DER base64(<前缀>.pub,
8
+ * 即 CAR_TRUST_ROOT 信任根值);sidecar = `<file>.minisig` 单段 base64(sigGate 读取口径)
9
+ * - 本模块只产出密钥与签名;门禁判定(warn/enforce 分级、fail-closed 分级)在 verifier/sigGate——
10
+ * sign 不判门。边界登记(1.1 规划 W2-1):minisign 离线单轨;sigstore keyless 主轨延后(1.2 候选)
11
+ */
12
+ import { createHash, generateKeyPairSync, sign as cryptoSign } from 'node:crypto'
13
+ import { readFileSync, writeFileSync } from 'node:fs'
14
+
15
+ export interface SigningKeypair {
16
+ /** PKCS8 DER 私钥字节(写 <前缀>.priv;离线保管不入仓库——secrets 红线) */
17
+ privateKeyDer: Buffer
18
+ /** SPKI DER base64 公钥(写 <前缀>.pub;即 CAR_TRUST_ROOT 信任根值) */
19
+ publicKeyBase64: string
20
+ }
21
+
22
+ /** ed25519 签名密钥对生成(指南 §2 第 1 步 node -e 流程固化) */
23
+ export function generateSigningKeypair(): SigningKeypair {
24
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519')
25
+ return {
26
+ privateKeyDer: privateKey.export({ type: 'pkcs8', format: 'der' }) as Buffer,
27
+ publicKeyBase64: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'),
28
+ }
29
+ }
30
+
31
+ export interface SignResult {
32
+ file: string
33
+ sidecar: string
34
+ /** 文件裸字节 sha256 hex(横幅指纹与审计留痕口径同 sigGate.manifestHash) */
35
+ manifestHash: string
36
+ }
37
+
38
+ /** 对单文件签名并写 sidecar `<file>.minisig`(指南 §2 第 2 步 node -e 流程固化) */
39
+ export function signPluginFile(file: string, privateKeyDer: Buffer): SignResult {
40
+ const manifestHash = createHash('sha256').update(readFileSync(file)).digest('hex')
41
+ const minisig = cryptoSign(null, Buffer.from(manifestHash), { key: privateKeyDer, format: 'der', type: 'pkcs8' }).toString('base64')
42
+ const sidecar = `${file}.minisig`
43
+ writeFileSync(sidecar, minisig)
44
+ return { file, sidecar, manifestHash }
45
+ }
46
+
47
+ /** 信任根解析(plugin-sign verify 用):显式 flag > CAR_TRUST_ROOT env > <前缀>.pub 文件 */
48
+ export function resolveTrustRoot(opts: { flag?: string; env?: NodeJS.ProcessEnv; pubFilePath?: string }): { value?: string; source: 'flag' | 'env' | 'pubfile' | 'none' } {
49
+ if (opts.flag) return { value: opts.flag, source: 'flag' }
50
+ const envVal = opts.env?.CAR_TRUST_ROOT
51
+ if (envVal) return { value: envVal, source: 'env' }
52
+ const pub = opts.pubFilePath
53
+ if (pub) {
54
+ try {
55
+ const v = readFileSync(pub, 'utf-8').trim()
56
+ if (v) return { value: v, source: 'pubfile' }
57
+ } catch { /* pub 文件不可读 → 视同未提供,由调用方显式报错 */ }
58
+ }
59
+ return { value: undefined, source: 'none' }
60
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * M8 · SQ-07 模型接入集成(chat → 流式消费 → assistant 落 M7 → ModelStep 交 M4 状态机)
3
+ *
4
+ * 时序对齐(§3.2.M8.4 SQ-07 逐行):
5
+ * 1. 请求前断言:assertModelVisibleLogged(配合 M7,N1 不变量前置);
6
+ * 2. 请求消息 = log.deriveMessages() 投影——Model-visible means logged 由构造保证
7
+ * (本层不引入任何未落链的模型可见输入:新增可见输入必须新增事件类型,§3.5.5 红线;
8
+ * system prompt 组装属 M4/M2 职责,v1 不在本层旁路);
9
+ * 3. 凭据解析在适配器内(resolve→reveal,A080001 在首块前抛出 → runTurn catch → turn error,
10
+ * 用户文案引导 car doctor);
11
+ * 4. 流式消费:delta 过 StreamRedactor(跨 chunk 命中不泄漏)+ toolCallDelta 聚合
12
+ * (半截 JSON 不解析——ADR-001 同纪律,length 时只交 truncatedTools);
13
+ * 5. assistant 文本事件落 M7(脱敏后 + secretsRedacted 计数留痕 meta);
14
+ * 6. finishReason 不可变透传为 ModelStep.stopReason(error/aborted → 'error',BD-04 收口
15
+ * 由 runTurn 落 turnEnd);工具调用事件由 runTurn executeBatch 落(本层不重复)。
16
+ */
17
+ import type { SessionLog } from '../session/log.ts'
18
+ import type { ModelStep, ToolCall } from '../loop/stop.ts'
19
+ import type { RuntimeCore } from './llm.ts'
20
+ import type { ToolDefinition } from './types.ts'
21
+ import { StreamRedactor } from './redaction.ts'
22
+ import { scanSecrets } from '../security/secrets.ts'
23
+ import { providerUnreachable } from './errors.ts'
24
+
25
+ export interface ChatStepOptions {
26
+ core: RuntimeCore
27
+ log: SessionLog
28
+ turnId: string
29
+ /** 模型名(provider 侧路由) */
30
+ model: string
31
+ /** 工具声明面(ToolDefinition 含 declaredSideEffect;不出站权限面仅 schema) */
32
+ tools: ToolDefinition[]
33
+ maxTokens?: number
34
+ adapterId?: string
35
+ }
36
+
37
+ /** ModelStep 加法扩展:脱敏计数留痕(S15 secretsRedacted 同名口径) */
38
+ export type ChatStepResult = ModelStep & { secretsRedacted: number }
39
+
40
+ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
41
+ const { core, log, turnId, model, tools, maxTokens, adapterId } = opts
42
+
43
+ // 1. 请求前断言(SQ-07 #2):N1 失守 = 带病请求,显式拒绝
44
+ const asserted = log.assertModelVisibleLogged()
45
+ if (!asserted.ok) {
46
+ throw new Error(`CAR-E-N1: 模型请求前快照与日志投影失配 @ atSeq=${asserted.failedAt}(Model-visible means logged 违规)`)
47
+ }
48
+
49
+ // 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐;
50
+ // assistant 工具调用以 { toolCall } content 形态原样透传——deriveMessages 投影即模型可见流)
51
+ const messages = log.deriveMessages().map(m => ({
52
+ role: m.role as 'user' | 'assistant' | 'toolResult',
53
+ content: m.content,
54
+ }))
55
+
56
+ // 4. 流式消费(3. 凭据解析在适配器首块前,A080001 由此传播)
57
+ const redactor = new StreamRedactor()
58
+ let text = ''
59
+ const calls = new Map<number, { id?: string; name?: string; argsBuf: string }>()
60
+ let stopReason: ModelStep['stopReason'] | undefined
61
+ let errorDetail: string | undefined
62
+
63
+ const stream = core.chat({
64
+ model,
65
+ messages,
66
+ tools,
67
+ ...(maxTokens != null ? { maxTokens } : {}),
68
+ ...(adapterId ? { adapterId } : {}),
69
+ ...(opts.log.sessionId ? { metadata: { sessionId: opts.log.sessionId, turnId, traceId: `${opts.log.sessionId}:${turnId}` } } : {}),
70
+ })
71
+ for await (const chunk of stream) {
72
+ if (chunk.delta !== undefined) text += redactor.push(chunk.delta)
73
+ if (chunk.toolCallDelta) {
74
+ const idx = chunk.toolCallDelta.index ?? 0
75
+ const cur = calls.get(idx) ?? { argsBuf: '' }
76
+ if (chunk.toolCallDelta.id) cur.id = chunk.toolCallDelta.id
77
+ if (chunk.toolCallDelta.name) cur.name = chunk.toolCallDelta.name
78
+ if (chunk.toolCallDelta.argumentsDelta) cur.argsBuf += chunk.toolCallDelta.argumentsDelta
79
+ calls.set(idx, cur)
80
+ }
81
+ if (chunk.error) errorDetail = chunk.error.message
82
+ if (chunk.finishReason !== undefined) stopReason = chunk.finishReason === 'aborted' ? 'error' : chunk.finishReason
83
+ }
84
+ if (stopReason === undefined) {
85
+ // 适配器层已保证首 finishReason(AL-05);防御性兜底显式化
86
+ throw new Error('CAR-E-LLM-FINISH: 流结束无 finishReason(适配器契约违规)')
87
+ }
88
+ text += redactor.flush()
89
+
90
+ // 5. assistant 文本落 M7(脱敏后 + 计数留痕;空文本不产事件)
91
+ // flush 后兜底复扫只计数不复写:流式遮蔽已生效,>0 即流式边界逃逸的审计信号
92
+ let secretsRedacted = 0
93
+ if (text.length) {
94
+ secretsRedacted = scanSecrets(text).hits.length
95
+ log.append('model', 'assistant', turnId, text, { secretsRedacted })
96
+ }
97
+
98
+ // 6. finishReason → ModelStep(不可变透传;length 不解析 args——ADR-001 解析前收口)
99
+ if (stopReason === 'length') {
100
+ const truncated = [...calls.values()].filter(c => c.id || c.name).map(c => ({ id: c.id ?? `tc-${c.name ?? 'unknown'}`, tool: c.name ?? 'unknown' }))
101
+ return { stopReason: 'length', ...(text ? { text } : {}), truncatedTools: truncated, secretsRedacted }
102
+ }
103
+ if (stopReason === 'toolUse') {
104
+ const toolCalls: ToolCall[] = []
105
+ for (const [idx, c] of [...calls.entries()].sort((a, b) => a[0] - b[0])) {
106
+ if (!c.name) continue
107
+ let args: Record<string, unknown>
108
+ try { args = c.argsBuf ? JSON.parse(c.argsBuf) as Record<string, unknown> : {} } catch {
109
+ // toolUse 声明下 args 非法 JSON = 协议违规显式化(非 length 场景无 truncatedTools 口径)
110
+ throw new Error(`CAR-E-LLM-PROTO: 工具调用 ${c.name} 参数非法 JSON(finishReason=${stopReason},${errorDetail ?? '无附加错误'})`)
111
+ }
112
+ toolCalls.push({ id: c.id ?? `tc-${idx}-${c.name}`, tool: c.name, args })
113
+ }
114
+ return { stopReason: 'toolUse', ...(text ? { text } : {}), ...(toolCalls.length ? { toolCalls } : {}), secretsRedacted }
115
+ }
116
+ // error(含 aborted 映射):以 B080001 抛出 → runTurn catch → turnEnd error 携带 detail(BD-04 收口)
117
+ if (stopReason === 'error') {
118
+ throw providerUnreachable(errorDetail ?? 'provider 返回 error 终止(无附加说明)')
119
+ }
120
+ return { stopReason: 'stop', ...(text ? { text } : {}), secretsRedacted }
121
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * M8 · 凭据门(resolveCredential ——《系统设计》§3.2.M8.2 凭据行 / SQ-07 凭据解析 / O-13)
3
+ *
4
+ * 口径:
5
+ * - 解析链:OS Keychain(C-03)→ env fallback(**需用户显式开启**——§3.2.M8.3 字段表
6
+ * 「env 需用户显式开启 fallback」);全落空 = CarM8Error A080001(不重试,引导 car doctor);
7
+ * - Keychain 通道按平台探测(能力探测三件套:命令在场的显式探测,缺席显式降级不静默):
8
+ * darwin = `security find-generic-password`、linux = `secret-tool`、
9
+ * win32 = 零依赖读通道缺席(cmdkey 仅列举不可读值)→ 显式降级登记;
10
+ * - CredentialRef 不落明文(O-13):ref 只含 provider/source/origin;明文经 reveal() 按需取用,
11
+ * reveal 必须经审计回调(provider+source 留痕,值不入审计——值入日志即泄露面,secrets 红线);
12
+ * - 环境变量约定:OPENAI_API_KEY(openai-compat)/ ANTHROPIC_API_KEY(anthropic)/
13
+ * CAR_LLM_API_KEY(通用兜底);显式开启 = opts.allowEnvFallback 或 CAR_ALLOW_ENV_CREDENTIALS=1。
14
+ */
15
+ import { spawnSync } from 'node:child_process'
16
+ import { CarM8Error, credentialMissing } from './errors.ts'
17
+ import type { CredentialRef, CredentialSource } from './types.ts'
18
+
19
+ export interface CredentialResolveOptions {
20
+ /** 测试注入;缺省取 process.env */
21
+ env?: NodeJS.ProcessEnv
22
+ /** env fallback 显式开启(规格要求默认关;CAR_ALLOW_ENV_CREDENTIALS=1 等效) */
23
+ allowEnvFallback?: boolean
24
+ /**
25
+ * keychain 读取器注入(测试替身):入参 keychain 服务名,返回明文或 null(不存在)。
26
+ * 缺省用平台命令真实读取;平台无读通道(win32)= 通道不可用(显式降级)。
27
+ */
28
+ keychainReader?: (service: string) => string | null
29
+ /** reveal 审计回调(值不入参——审计全量留痕但零明文) */
30
+ audit?: (event: { action: 'resolve' | 'reveal'; provider: string; source: CredentialSource; origin: string }) => void
31
+ }
32
+
33
+ /** provider → 候选 env 变量(按序检索;CAR_LLM_API_KEY 为通用兜底) */
34
+ const PROVIDER_ENV_VARS: Record<string, string[]> = {
35
+ 'openai-compat': ['OPENAI_API_KEY'],
36
+ anthropic: ['ANTHROPIC_API_KEY'],
37
+ }
38
+ const GENERIC_ENV_VARS = ['CAR_LLM_API_KEY']
39
+
40
+ /** keychain 服务名(命名空间隔离:car-runtime/<provider>) */
41
+ export function keychainService(provider: string): string {
42
+ return `car-runtime/${provider}`
43
+ }
44
+
45
+ interface KeychainChannel { reader: ((service: string) => string | null) | null; note: string }
46
+
47
+ /** 平台 keychain 通道探测:命令在场(darwin/linux)→ 真实读取器;无读通道(win32)→ 显式 null */
48
+ export function probeKeychainChannel(platform: NodeJS.Platform = process.platform): KeychainChannel {
49
+ if (platform === 'darwin') {
50
+ return { reader: service => {
51
+ const r = spawnSync('security', ['find-generic-password', '-s', service, '-w'], { encoding: 'utf-8', timeout: 5000 })
52
+ if (r.status !== 0 || r.stdout == null) return null
53
+ return r.stdout.replace(/\r?\n$/, '') || null
54
+ }, note: 'darwin security' }
55
+ }
56
+ if (platform === 'linux') {
57
+ return { reader: service => {
58
+ const r = spawnSync('secret-tool', ['lookup', 'service', service], { encoding: 'utf-8', timeout: 5000 })
59
+ if (r.status !== 0 || r.stdout == null) return null
60
+ return r.stdout.replace(/\r?\n$/, '') || null
61
+ }, note: 'linux secret-tool' }
62
+ }
63
+ // win32 及其他:无零依赖读通道——显式不可用(禁静默假装有 keychain)
64
+ return { reader: null, note: `keychain 读通道在 ${platform} 缺席(零依赖实现无 OS 凭据库读取面)——显式降级` }
65
+ }
66
+
67
+ export class CredentialService {
68
+ readonly #channel: KeychainChannel
69
+ readonly #platform: NodeJS.Platform
70
+
71
+ constructor(opts: { keychainReader?: (service: string) => string | null; platform?: NodeJS.Platform } = {}) {
72
+ this.#platform = opts.platform ?? process.platform
73
+ this.#channel = opts.keychainReader
74
+ ? { reader: opts.keychainReader, note: 'injected reader' }
75
+ : probeKeychainChannel(this.#platform)
76
+ }
77
+
78
+ /** keychain 通道状态(doctor 展示/测试断言;缺席显式登记不静默) */
79
+ get channelStatus(): { available: boolean; note: string } {
80
+ return { available: this.#channel.reader !== null, note: this.#channel.note }
81
+ }
82
+
83
+ /**
84
+ * 解析凭据引用(天然幂等;返回值不含明文)。
85
+ * 检索顺序:keychain →(显式开启时)env;全落空 = A080001。
86
+ */
87
+ resolve(provider: string, opts: CredentialResolveOptions = {}): CredentialRef {
88
+ const env = opts.env ?? process.env
89
+ const searched: string[] = []
90
+ const service = keychainService(provider)
91
+
92
+ if (this.#channel.reader) {
93
+ searched.push(`keychain:${service}`)
94
+ const value = this.#channel.reader(service)
95
+ if (value) {
96
+ opts.audit?.({ action: 'resolve', provider, source: 'keychain', origin: service })
97
+ return { provider, source: 'keychain', origin: service }
98
+ }
99
+ } else {
100
+ searched.push(`keychain:${service}(通道缺席)`)
101
+ }
102
+
103
+ const allowEnv = opts.allowEnvFallback === true || env.CAR_ALLOW_ENV_CREDENTIALS === '1'
104
+ if (allowEnv) {
105
+ const candidates = [...(PROVIDER_ENV_VARS[provider] ?? []), ...GENERIC_ENV_VARS]
106
+ for (const name of candidates) {
107
+ searched.push(`env:${name}`)
108
+ if (env[name]) {
109
+ opts.audit?.({ action: 'resolve', provider, source: 'env', origin: name })
110
+ return { provider, source: 'env', origin: name, degraded: this.#channel.reader ? undefined : this.#channel.note }
111
+ }
112
+ }
113
+ } else {
114
+ searched.push('env(未显式开启 fallback)')
115
+ }
116
+
117
+ throw credentialMissing(provider, searched)
118
+ }
119
+
120
+ /** 按引用取明文(仅 provider 适配器请求时点调用;每次经审计留痕,值不入日志不入错误) */
121
+ reveal(ref: CredentialRef, opts: CredentialResolveOptions = {}): string {
122
+ const env = opts.env ?? process.env
123
+ if (ref.source === 'keychain') {
124
+ if (!this.#channel.reader) throw new CarM8Error('A080001', 'CAR-E-CRED-MISSING', `keychain 通道缺席,无法取值(${ref.origin})`, '未找到模型服务凭据,请运行 car doctor', false)
125
+ const value = this.#channel.reader(ref.origin)
126
+ if (!value) throw credentialMissing(ref.provider, [`keychain:${ref.origin}(resolve 后消失)`])
127
+ opts.audit?.({ action: 'reveal', provider: ref.provider, source: ref.source, origin: ref.origin })
128
+ return value
129
+ }
130
+ const value = env[ref.origin]
131
+ if (!value) throw credentialMissing(ref.provider, [`env:${ref.origin}(resolve 后消失)`])
132
+ opts.audit?.({ action: 'reveal', provider: ref.provider, source: ref.source, origin: ref.origin })
133
+ return value
134
+ }
135
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * M8 · 错误面(《系统设计》§3.5.1 六位错误码 + 代码库 CAR-E-* 前缀双轨)
3
+ *
4
+ * - A080001 凭据缺失:不重试,用户文案引导 car doctor(SQ-07 凭据缺失分支);
5
+ * - B080001 LLM Provider 不可达/重试耗尽:退避后重试(§3.5.4 基线——2 次指数退避 1s/2s);
6
+ * - 两条均为「错误路径不改写 finishReason 语义」的守门错误:流中途失败以
7
+ * finishReason='error' chunk 收口(BD-04),首块前失败以 CarError 抛出(runTurn catch → turn error)。
8
+ */
9
+
10
+ export type M8ErrorCode = 'A080001' | 'B080001'
11
+
12
+ export class CarM8Error extends Error {
13
+ constructor(
14
+ /** 六位错误码(§3.5.1 注册表) */
15
+ readonly code: M8ErrorCode,
16
+ /** CAR-E-* 代码库前缀(与 CAR-E-DEPCYCLE 等同风格) */
17
+ readonly slug: string,
18
+ message: string,
19
+ /** 用户文案(§3.5.1 注册表原文) */
20
+ readonly userHint?: string,
21
+ /** 重试建议(A080001 不重试 / B080001 退避后重试) */
22
+ readonly retryable = false,
23
+ ) {
24
+ super(`${slug}: ${message}(${code})`)
25
+ this.name = 'CarM8Error'
26
+ }
27
+ }
28
+
29
+ export function credentialMissing(provider: string, searched: string[]): CarM8Error {
30
+ return new CarM8Error(
31
+ 'A080001',
32
+ 'CAR-E-CRED-MISSING',
33
+ `未找到模型服务凭据(provider=${provider},已检索:${searched.join('、') || '无可用通道'})`,
34
+ '未找到模型服务凭据,请运行 car doctor',
35
+ false,
36
+ )
37
+ }
38
+
39
+ export function providerUnreachable(detail: string): CarM8Error {
40
+ return new CarM8Error(
41
+ 'B080001',
42
+ 'CAR-E-LLM-UNREACHABLE',
43
+ `LLM Provider 不可达/重试耗尽:${detail}`,
44
+ '模型服务暂时不可用,本轮已停止',
45
+ true,
46
+ )
47
+ }
@@ -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
+ }