@lqc123qwe/car-runtime 1.9.0 → 1.11.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.
Files changed (182) hide show
  1. package/README.md +17 -1
  2. package/dist/SHA256SUMS +1 -0
  3. package/dist/authz/authz.d.ts +46 -0
  4. package/dist/authz/authz.js +76 -0
  5. package/dist/car-runtime-1.11.0.tgz +0 -0
  6. package/dist/car-runtime-1.11.0.tgz.sig.bundle +1 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +825 -0
  9. package/dist/dx/doctor.d.ts +62 -0
  10. package/dist/dx/doctor.js +127 -0
  11. package/dist/governance/dump.d.ts +47 -0
  12. package/dist/governance/dump.js +17 -0
  13. package/dist/host/facade.d.ts +13 -0
  14. package/dist/host/facade.js +154 -0
  15. package/dist/host/hostGateway.d.ts +117 -0
  16. package/dist/host/hostGateway.js +65 -0
  17. package/dist/host/mappings.d.ts +27 -0
  18. package/dist/host/mappings.js +52 -0
  19. package/dist/host/stdio.d.ts +14 -0
  20. package/dist/host/stdio.js +83 -0
  21. package/dist/kernel/context.d.ts +76 -0
  22. package/dist/kernel/context.js +203 -0
  23. package/dist/kernel/events.d.ts +37 -0
  24. package/dist/kernel/events.js +82 -0
  25. package/dist/load/config.d.ts +79 -0
  26. package/dist/load/config.js +285 -0
  27. package/dist/load/loader.d.ts +72 -0
  28. package/dist/load/loader.js +162 -0
  29. package/dist/load/registry.d.ts +44 -0
  30. package/dist/load/registry.js +58 -0
  31. package/dist/load/report.d.ts +98 -0
  32. package/dist/load/report.js +301 -0
  33. package/dist/load/sigGate.d.ts +22 -0
  34. package/dist/load/sigGate.js +68 -0
  35. package/dist/load/sign.d.ts +25 -0
  36. package/dist/load/sign.js +47 -0
  37. package/dist/load/verifier.d.ts +35 -0
  38. package/dist/load/verifier.js +56 -0
  39. package/dist/loop/goal.d.ts +31 -0
  40. package/dist/loop/goal.js +34 -0
  41. package/dist/loop/recover.d.ts +17 -0
  42. package/dist/loop/recover.js +39 -0
  43. package/dist/loop/stop.d.ts +82 -0
  44. package/dist/loop/stop.js +184 -0
  45. package/dist/mcp/gateway.d.ts +96 -0
  46. package/dist/mcp/gateway.js +69 -0
  47. package/dist/mcp/httpClient.d.ts +27 -0
  48. package/dist/mcp/httpClient.js +196 -0
  49. package/dist/mcp/stdioClient.d.ts +8 -0
  50. package/dist/mcp/stdioClient.js +73 -0
  51. package/dist/ptc/budget.d.ts +21 -0
  52. package/dist/ptc/budget.js +36 -0
  53. package/dist/ptc/erasable.d.ts +14 -0
  54. package/dist/ptc/erasable.js +27 -0
  55. package/dist/ptc/runCode.d.ts +65 -0
  56. package/dist/ptc/runCode.js +140 -0
  57. package/dist/ptc/schemaRender.d.ts +15 -0
  58. package/dist/ptc/schemaRender.js +90 -0
  59. package/dist/ptc/sdk.d.ts +27 -0
  60. package/dist/ptc/sdk.js +23 -0
  61. package/dist/ptc/worker-entry.d.ts +1 -0
  62. package/dist/ptc/worker-entry.js +71 -0
  63. package/dist/release-audit.jsonl +4 -0
  64. package/dist/runtime-core/chatStep.d.ts +43 -0
  65. package/dist/runtime-core/chatStep.js +121 -0
  66. package/dist/runtime-core/credentials.d.ts +49 -0
  67. package/dist/runtime-core/credentials.js +118 -0
  68. package/dist/runtime-core/errors.d.ts +30 -0
  69. package/dist/runtime-core/errors.js +36 -0
  70. package/dist/runtime-core/llm.d.ts +76 -0
  71. package/dist/runtime-core/llm.js +638 -0
  72. package/dist/runtime-core/redaction.d.ts +39 -0
  73. package/dist/runtime-core/redaction.js +79 -0
  74. package/dist/runtime-core/telemetry.d.ts +88 -0
  75. package/dist/runtime-core/telemetry.js +332 -0
  76. package/dist/runtime-core/tools.d.ts +15 -0
  77. package/dist/runtime-core/tools.js +12 -0
  78. package/dist/runtime-core/trace.d.ts +55 -0
  79. package/dist/runtime-core/trace.js +137 -0
  80. package/dist/runtime-core/types.d.ts +104 -0
  81. package/dist/runtime-core/types.js +13 -0
  82. package/dist/sandbox/sandbox.d.ts +68 -0
  83. package/dist/sandbox/sandbox.js +153 -0
  84. package/dist/sbom.json +263 -0
  85. package/dist/seam.d.ts +24 -0
  86. package/dist/seam.js +23 -0
  87. package/dist/security/secrets-baseline.d.ts +43 -0
  88. package/dist/security/secrets-baseline.js +176 -0
  89. package/dist/security/secrets.d.ts +45 -0
  90. package/dist/security/secrets.js +138 -0
  91. package/dist/session/export.d.ts +55 -0
  92. package/dist/session/export.js +81 -0
  93. package/dist/session/fork.d.ts +51 -0
  94. package/dist/session/fork.js +63 -0
  95. package/dist/session/format.d.ts +21 -0
  96. package/dist/session/format.js +49 -0
  97. package/dist/session/indexStore.d.ts +44 -0
  98. package/dist/session/indexStore.js +265 -0
  99. package/dist/session/log.d.ts +79 -0
  100. package/dist/session/log.js +157 -0
  101. package/dist/session/store.d.ts +9 -0
  102. package/dist/session/store.js +37 -0
  103. package/dist/telemetry/metrics.d.ts +25 -0
  104. package/dist/telemetry/metrics.js +26 -0
  105. package/package.json +17 -1
  106. package/src/authz/authz.ts +12 -0
  107. package/src/loop/stop.ts +43 -10
  108. package/src/runtime-core/chatStep.ts +22 -2
  109. package/src/runtime-core/llm.ts +96 -13
  110. package/src/runtime-core/types.ts +22 -1
  111. package/src/seam.ts +24 -0
  112. package/src/session/log.ts +1 -1
  113. package/.gitattributes +0 -11
  114. package/.github/workflows/ci.yml +0 -293
  115. package/.github/workflows/release.yml +0 -54
  116. package/adr/ADR-001.md +0 -4
  117. package/adr/ADR-002.md +0 -5
  118. package/adr/ADR-003.md +0 -35
  119. package/adr/host-onboarding.md +0 -68
  120. package/adr/mlps-audit-checklist.md +0 -41
  121. package/adr/win32-spike.md +0 -45
  122. package/docs/plugin-authoring.md +0 -82
  123. package/native/landlock-run/Makefile +0 -19
  124. package/native/landlock-run/landlock-run.c +0 -162
  125. package/scripts/contract-check.ts +0 -56
  126. package/scripts/invariant-n1.ts +0 -96
  127. package/scripts/package-check.ts +0 -29
  128. package/scripts/ptc-baseline/run-baseline.ts +0 -118
  129. package/scripts/release-artifacts.ts +0 -111
  130. package/scripts/release-pipeline.ts +0 -245
  131. package/scripts/secrets-baseline.ts +0 -33
  132. package/scripts/secrets-scan.ts +0 -56
  133. package/scripts/win32-ffi-debug.ts +0 -143
  134. package/scripts/win32-koffi-spike.ts +0 -537
  135. package/test/contract/eventmap.golden.json +0 -7
  136. package/test/fixtures/hello-plugin.ts +0 -5
  137. package/test/kernel.spec.ts +0 -136
  138. package/test/registry-e2e/run-e2e.ts +0 -213
  139. package/test/s11.spec.ts +0 -129
  140. package/test/s12.spec.ts +0 -135
  141. package/test/s13.spec.ts +0 -117
  142. package/test/s14.spec.ts +0 -117
  143. package/test/s15.spec.ts +0 -80
  144. package/test/s16.spec.ts +0 -133
  145. package/test/s17.spec.ts +0 -102
  146. package/test/s18.spec.ts +0 -81
  147. package/test/s19.spec.ts +0 -138
  148. package/test/s2.spec.ts +0 -245
  149. package/test/s20.spec.ts +0 -241
  150. package/test/s21.spec.ts +0 -231
  151. package/test/s22-m7-store-format.spec.ts +0 -135
  152. package/test/s23-m7-export-index.spec.ts +0 -189
  153. package/test/s24-signature-gate.spec.ts +0 -185
  154. package/test/s25-m8-runtime-core.spec.ts +0 -600
  155. package/test/s26-signature-closeout.spec.ts +0 -349
  156. package/test/s27-host-load-wiring.spec.ts +0 -134
  157. package/test/s28-session-index-followup.spec.ts +0 -236
  158. package/test/s29-otel-producer.spec.ts +0 -252
  159. package/test/s3.spec.ts +0 -179
  160. package/test/s30-release-artifacts.spec.ts +0 -127
  161. package/test/s31-real-model-wiring.spec.ts +0 -329
  162. package/test/s32-telemetry-mcp-ptc.spec.ts +0 -218
  163. package/test/s33-anthropic-adapter.spec.ts +0 -354
  164. package/test/s34-mcp-remote-transport.spec.ts +0 -250
  165. package/test/s35-config-ts-zod.spec.ts +0 -145
  166. package/test/s36-host-tool-surface.spec.ts +0 -250
  167. package/test/s37-projection-dedupe.spec.ts +0 -161
  168. package/test/s38-config-jiti.spec.ts +0 -116
  169. package/test/s39-otlp-span-semantics.spec.ts +0 -100
  170. package/test/s4.spec.ts +0 -169
  171. package/test/s40-metrics-cumulative.spec.ts +0 -67
  172. package/test/s41-mcp-serve-turn-span.spec.ts +0 -148
  173. package/test/s42-traceparent-propagation.spec.ts +0 -415
  174. package/test/s43-remote-http-traceparent.spec.ts +0 -262
  175. package/test/s5.spec.ts +0 -95
  176. package/test/s6.spec.ts +0 -169
  177. package/test/s7.spec.ts +0 -136
  178. package/test/s7b-baseline.spec.ts +0 -20
  179. package/test/s9.spec.ts +0 -89
  180. package/test/sandbox-escape/j17-orchestrate.sh +0 -30
  181. package/test/sandbox-escape/run-matrix.sh +0 -98
  182. package/tsconfig.json +0 -18
@@ -0,0 +1,638 @@
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,D-12a
13
+ * loopback 豁免共用 assertTlsOrLoopback);重试/超时/取消引擎单点共用(1.5-S1 streamSsePost
14
+ * ——openai-compat / anthropic 语义不漂移,parse 为唯一 provider 差异点);
15
+ * - declaredSideEffect 不出站(权限面只进权限门,不进模型请求体);
16
+ * - 1.5-S1(D-20):内置 anthropic 适配器(Messages API 流式;max_tokens 必填缺省 4096;
17
+ * system 顶层化;凭据 provider='anthropic')。
18
+ * - 1.8-BUG-1:出站缺省通道 node:http(s)(nodeHttpSseFetch——1.4-BUG-2 口径延伸到 LLM 面):
19
+ * 全局 fetch/undici socket 在 win32 进程退出时触发 libuv 断言崩溃(UV_HANDLE_CLOSING,
20
+ * 0xC0000409)——car run 真路径 ≥3 次模型请求 + PTC worker/MCP 子进程并存即确定性复现
21
+ * (1.4 彼时仅修遥测出站,LLM 出站同为 undici 默认——潜伏至 1.8 E2E 三请求形态暴露);
22
+ * 流式契约 body = AsyncIterable<Uint8Array> 不变;测试注入缝 fetchImpl 保留。
23
+ * - 1.11-S1(D-33 / 计量设计 §3.2):usage 采集——openai 请求体加 stream_options.include_usage
24
+ * (默认开启,第三方网关不兼容时回退 = baseUrl 白名单,登记可回退项;缺失走缺失口径禁估算),
25
+ * 解析「先摘 evt.usage 再判 choice」(空 choices 尾包不再被吞);anthropic message_start(input 侧)
26
+ * + message_delta(output 侧)两包合并(message_delta 缺席 = output 不完整,整条不落);
27
+ * 归一口径 §1.1(openai 减法 / anthropic 透传 + cache_creation),畸形/缺失整条不落(禁补造)。
28
+ */
29
+ import { providerUnreachable } from './errors.js';
30
+ import { CredentialService } from './credentials.js';
31
+ import { request as httpRequest } from 'node:http';
32
+ import { request as httpsRequest } from 'node:https';
33
+ // ==================== 适配器注册表(registerLlmAdapter) ====================
34
+ export class AdapterRegistry {
35
+ #adapters = new Map();
36
+ #defaultId = null;
37
+ /** 注册(幂等语义:同 id 重复注册显式报错——冲突链定位风格) */
38
+ register(adapter, opts = {}) {
39
+ if (this.#adapters.has(adapter.id)) {
40
+ throw new Error(`CAR-E-LLM-DUP: LLM adapter "${adapter.id}" already registered (duplicate registration is blocked)`);
41
+ }
42
+ this.#adapters.set(adapter.id, adapter);
43
+ if (opts.default || this.#defaultId === null)
44
+ this.#defaultId = adapter.id;
45
+ let disposed = false;
46
+ return {
47
+ dispose: () => {
48
+ if (disposed)
49
+ return; // 幂等回卷(二次 dispose 静默收敛,登记口径)
50
+ disposed = true;
51
+ this.#adapters.delete(adapter.id);
52
+ if (this.#defaultId === adapter.id)
53
+ this.#defaultId = this.#adapters.keys().next().value ?? null;
54
+ },
55
+ };
56
+ }
57
+ /** 解析:显式 id > 默认适配器;无可用适配器显式报错(禁静默降级) */
58
+ get(id) {
59
+ const target = id ?? this.#defaultId;
60
+ if (!target || !this.#adapters.has(target)) {
61
+ const available = [...this.#adapters.keys()].join(', ') || '无';
62
+ throw new Error(`CAR-E-LLM-NOADAPTER: LLM adapter "${target ?? '<default>'}" not registered(在册:${available})`);
63
+ }
64
+ return this.#adapters.get(target);
65
+ }
66
+ list() {
67
+ return [...this.#adapters.keys()].map(id => ({ id, isDefault: id === this.#defaultId }));
68
+ }
69
+ }
70
+ // ==================== finishReason 不可变守卫(SDK 边界) ====================
71
+ /** 首个 finishReason 定格;适配器后续给出不同值 = 契约违规显式抛错(不改写不吞没) */
72
+ export async function* withFinishReasonGuard(stream) {
73
+ let settled;
74
+ for await (const chunk of stream) {
75
+ if (chunk.finishReason !== undefined) {
76
+ if (settled === undefined)
77
+ settled = chunk.finishReason;
78
+ else if (settled !== chunk.finishReason) {
79
+ throw new Error(`CAR-E-LLM-FINISH: finishReason 已定格为 "${settled}",适配器试图给出 "${chunk.finishReason}"(不可变透传契约违规)`);
80
+ }
81
+ }
82
+ yield chunk;
83
+ }
84
+ }
85
+ // ==================== RuntimeCore(M8 SDK 面) ====================
86
+ export class RuntimeCore {
87
+ registry = new AdapterRegistry();
88
+ credentials;
89
+ #ctx = null;
90
+ constructor(credentials = new CredentialService()) {
91
+ this.credentials = credentials;
92
+ }
93
+ /** 绑定插件作用域:服务注册 + 适配器注册进入 Effect 可逆通道(宿主传 Context——根作用域 effect 同纪律) */
94
+ bindContext(ctx) {
95
+ this.#ctx = ctx;
96
+ ctx.provide('runtime-core', this);
97
+ }
98
+ /** §3.2.M8.2:registerLlmAdapter(adapter): Disposable——Effect 可逆(卸载回卷后可重注册) */
99
+ registerLlmAdapter(adapter, opts = {}) {
100
+ if (!this.#ctx)
101
+ return this.registry.register(adapter, opts);
102
+ let handle = null;
103
+ this.#ctx.effect(() => {
104
+ handle = this.registry.register(adapter, opts);
105
+ return () => handle?.dispose();
106
+ }, `llm-adapter:${adapter.id}`);
107
+ return { dispose: () => handle?.dispose() };
108
+ }
109
+ /** §3.2.M8.2:chat(req): AsyncIterable<LlmChunk>——统一入口 + finishReason 守卫 */
110
+ chat(req) {
111
+ const adapter = this.registry.get(req.adapterId);
112
+ return withFinishReasonGuard(adapter.chat(req));
113
+ }
114
+ }
115
+ // ==================== 适配器共用基线(1.5-S1 抽取:D-12a TLS 门 + §3.5.4 流式引擎) ====================
116
+ /**
117
+ * D-12a TLS 强制门(openai-compat / anthropic 共用,口径不变):https 直接通过;
118
+ * http 仅豁免 loopback——本地模型服务(Ollama 等)与本地 E2E;非回环地址维持 TLS 强制
119
+ * (§3.2.M8.4)。豁免集显式列举,不扩大到局域网。
120
+ */
121
+ export function assertTlsOrLoopback(baseUrl) {
122
+ if (baseUrl.startsWith('https://'))
123
+ return;
124
+ let loopback = false;
125
+ try {
126
+ const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, '');
127
+ loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1';
128
+ }
129
+ catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
130
+ if (!loopback) {
131
+ throw new Error(`CAR-E-LLM-TLS: baseUrl 必须 https(TLS 强制,§3.2.M8.4);http 仅豁免 loopback(127.0.0.1/[::1]/localhost,D-12a)——远程明文部署请经 TLS 代理或自建适配器`);
132
+ }
133
+ }
134
+ /**
135
+ * 1.8-BUG-1:LLM 出站缺省通道——node:http(s) 流式 POST(1.4-BUG-2 同源口径:全局 fetch/undici
136
+ * socket + win32 进程退出触发 libuv 断言崩溃 0xC0000409;详见面头注记)。流式契约:IncomingMessage
137
+ * 本体即 AsyncIterable<Uint8Array>(Buffer 子类);取消经 AbortSignal → req.destroy(流中途 destroy
138
+ * = 迭代器抛错,streamSsePost D-19 路径收口);传输层超时不设——§3.5.4 firstByte/overall 由调用方
139
+ * AbortController 单点承载。
140
+ */
141
+ const nodeHttpSseFetch = (url, init = {}) => new Promise((resolve, reject) => {
142
+ try {
143
+ const u = new URL(url);
144
+ const mod = u.protocol === 'https:' ? httpsRequest : httpRequest;
145
+ const req = mod(u, { method: init.method ?? 'GET', headers: init.headers }, res => {
146
+ const status = res.statusCode ?? 0;
147
+ resolve({
148
+ ok: status >= 200 && status < 300,
149
+ status,
150
+ text: () => new Promise((res2, rej2) => {
151
+ let s = '';
152
+ res.on('data', c => { s += c.toString('utf-8'); });
153
+ res.on('end', () => res2(s));
154
+ res.on('error', rej2);
155
+ }),
156
+ body: res,
157
+ });
158
+ });
159
+ const onAbort = () => { try {
160
+ req.destroy(new Error('aborted'));
161
+ }
162
+ catch { /* 已完成 */ } };
163
+ if (init.signal) {
164
+ if (init.signal.aborted)
165
+ onAbort();
166
+ else
167
+ init.signal.addEventListener('abort', onAbort, { once: true });
168
+ }
169
+ req.on('error', reject);
170
+ req.end(init.body ?? null);
171
+ }
172
+ catch (e) {
173
+ reject(e);
174
+ }
175
+ });
176
+ /**
177
+ * §3.5.4 流式 POST 引擎(openai-compat / anthropic 单点共用,1.5-S1 抽取——两适配器重试/
178
+ * 超时/取消语义不漂移):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;首块前失败才可重试
179
+ * (已消费 chunk 不可重放);重试耗尽 = CarM8Error B080001;外部取消 ≠ 失败(D-19:静默
180
+ * 收口不 retry 不 error chunk);流自然结束未携带 finishReason = AL-05 显式 error chunk。
181
+ * `parse` 为唯一 provider 差异点(SSE 字节流 → LlmChunk)。
182
+ */
183
+ async function* streamSsePost(spec, parse) {
184
+ const payload = await spec.buildRequest();
185
+ for (let attempt = 0;; attempt++) {
186
+ // D-19:外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
187
+ if (spec.signal?.aborted)
188
+ return;
189
+ const controller = new AbortController();
190
+ const started = Date.now();
191
+ let firstByteSeen = false;
192
+ const firstByteTimer = setTimeout(() => controller.abort(), spec.firstByteMs);
193
+ const overallTimer = setTimeout(() => controller.abort(), spec.timeoutMs);
194
+ try {
195
+ const res = await spec.fetchImpl(spec.url, { ...payload, signal: controller.signal });
196
+ if (res.status === 429 || res.status >= 500)
197
+ throw new Retryable(`HTTP ${res.status}`);
198
+ if (!res.ok) {
199
+ const text = await res.text().catch(() => '');
200
+ // 4xx 业务错不重试(§3.5.4);错误信息不含凭据(凭据值不进错误路径)
201
+ yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } };
202
+ return;
203
+ }
204
+ if (!res.body)
205
+ throw new Retryable('响应无 body 流');
206
+ let sawFinish = false;
207
+ for await (const chunk of parse(res.body)) {
208
+ // D-19:chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,不违反
209
+ // finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
210
+ if (spec.signal?.aborted) {
211
+ controller.abort();
212
+ return;
213
+ }
214
+ if (!firstByteSeen) {
215
+ firstByteSeen = true;
216
+ clearTimeout(firstByteTimer);
217
+ }
218
+ if (chunk.finishReason !== undefined)
219
+ sawFinish = true;
220
+ yield chunk;
221
+ }
222
+ // 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
223
+ // 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1 口径延续)
224
+ if (!sawFinish) {
225
+ yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } };
226
+ }
227
+ return;
228
+ }
229
+ catch (e) {
230
+ // D-19:外部取消(abort 连锁的 fetch 异常)≠ 失败——静默收口,不走 retry
231
+ if (spec.signal?.aborted)
232
+ return;
233
+ const beforeFirstByte = !firstByteSeen;
234
+ clearTimeout(firstByteTimer);
235
+ // 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何 chunk
236
+ if (beforeFirstByte && attempt < spec.retries) {
237
+ await spec.sleep(spec.backoffMs[Math.min(attempt, spec.backoffMs.length - 1)]);
238
+ continue;
239
+ }
240
+ if (beforeFirstByte) {
241
+ // 重试耗尽 → B080001(BD-04:消费方 runTurn 以 error 收口)
242
+ throw providerUnreachable(`${e.name}: ${e.message}(尝试 ${attempt + 1}/${spec.retries + 1},${Date.now() - started}ms)`);
243
+ }
244
+ // 流中途失败:已消费 chunk 不可重放 → error chunk 收口
245
+ yield { finishReason: 'error', error: { code: 'B080001', message: `流中途失败:${e.message}` } };
246
+ return;
247
+ }
248
+ finally {
249
+ clearTimeout(firstByteTimer);
250
+ clearTimeout(overallTimer);
251
+ }
252
+ }
253
+ }
254
+ const FINISH_MAP = {
255
+ stop: 'stop',
256
+ length: 'length',
257
+ tool_calls: 'toolUse',
258
+ function_call: 'toolUse',
259
+ content_filter: 'aborted',
260
+ };
261
+ class Retryable extends Error {
262
+ }
263
+ /**
264
+ * 1.11-S1(D-33 / 计量设计 §1.1):openai usage 归一——prompt_tokens 含 cached,inputTokens
265
+ * 必须做减法(prompt − prompt_tokens_details.cached_tokens);cache_creation 恒 0(无对应字段,
266
+ * 登记不估算)。usage 对象缺席/畸形(两侧主字段任一非数值)= 整条不落(缺失口径,禁补造)。
267
+ */
268
+ function normalizeOpenAIUsage(u, model, adapterId) {
269
+ if (!u || typeof u !== 'object')
270
+ return undefined;
271
+ const r = u;
272
+ if (typeof r.prompt_tokens !== 'number' || typeof r.completion_tokens !== 'number')
273
+ return undefined;
274
+ const details = (r.prompt_tokens_details ?? null);
275
+ const cached = details && typeof details.cached_tokens === 'number' ? details.cached_tokens : 0;
276
+ return {
277
+ inputTokens: r.prompt_tokens - cached,
278
+ outputTokens: r.completion_tokens,
279
+ cacheReadTokens: cached,
280
+ cacheCreationTokens: 0,
281
+ model,
282
+ adapterId,
283
+ providerRaw: u,
284
+ };
285
+ }
286
+ /** 明文 SSE → LlmChunk 流解析(增量容错:半行驻留到下一 chunk;1.11-S1:model/adapterId 供 usage 归一) */
287
+ async function* parseSseStream(body, model, adapterId) {
288
+ const decoder = new TextDecoder();
289
+ let buf = '';
290
+ for await (const bytes of body) {
291
+ buf += decoder.decode(bytes, { stream: true });
292
+ let nl;
293
+ while ((nl = buf.indexOf('\n')) >= 0) {
294
+ const line = buf.slice(0, nl).replace(/\r$/, '');
295
+ buf = buf.slice(nl + 1);
296
+ if (!line.startsWith('data:'))
297
+ continue;
298
+ const data = line.slice(5).trim();
299
+ if (data === '[DONE]')
300
+ return;
301
+ if (!data)
302
+ continue;
303
+ let evt;
304
+ try {
305
+ evt = JSON.parse(data);
306
+ }
307
+ catch {
308
+ continue;
309
+ } // 心跳/注释行容错(显式跳过非 JSON data)
310
+ // 1.11-S1(D-33 / 计量设计 §3.2 必核行):先摘 usage 再判 choice——stream_options.include_usage
311
+ // 的尾包 choices 为空,下方 `if (!choice) continue` 原样会把它吞掉
312
+ const usage = evt.usage !== undefined ? normalizeOpenAIUsage(evt.usage, model, adapterId) : undefined;
313
+ const choice = evt.choices?.[0];
314
+ if (!choice) {
315
+ // 独立 usage 尾包(官方形态:finish_reason chunk 之后、[DONE] 之前)——请求最后一个 chunk
316
+ if (usage)
317
+ yield { usage };
318
+ continue;
319
+ }
320
+ const delta = choice.delta ?? {};
321
+ const raw = choice.finish_reason;
322
+ const finish = raw == null ? undefined : (() => {
323
+ const mapped = FINISH_MAP[String(raw)];
324
+ return mapped
325
+ ? mapped
326
+ : 'error'; // 未映射信号 fail-visible(禁静默改写),raw 附在 error.message
327
+ })();
328
+ const toolCallDeltas = Array.isArray(delta.tool_calls)
329
+ ? delta.tool_calls.map((tc) => ({
330
+ index: tc.index,
331
+ id: tc.id,
332
+ name: tc.function?.name ?? tc.name,
333
+ argumentsDelta: tc.function?.arguments,
334
+ }))
335
+ : [];
336
+ const text = typeof delta.content === 'string' && delta.content.length ? delta.content : undefined;
337
+ // LlmChunk 单 toolCallDelta 口径:同事件多条 tool_calls 顺序展开为多 chunk
338
+ if (!text && !toolCallDeltas.length && finish === undefined) {
339
+ // 空事件但携 usage(部分 compat 网关形态):usage 不随空事件丢失
340
+ if (usage)
341
+ yield { usage };
342
+ continue;
343
+ }
344
+ const chunks = [];
345
+ if (toolCallDeltas.length === 0) {
346
+ chunks.push({
347
+ ...(text !== undefined ? { delta: text } : {}),
348
+ ...(finish !== undefined
349
+ ? { finishReason: finish, ...(finish === 'error' ? { error: { code: 'B080001', message: `未映射的 finish_reason "${raw}"` } } : {}) }
350
+ : {}),
351
+ });
352
+ }
353
+ else {
354
+ for (let i = 0; i < toolCallDeltas.length; i++) {
355
+ const last = i === toolCallDeltas.length - 1;
356
+ chunks.push({
357
+ ...(i === 0 && text !== undefined ? { delta: text } : {}),
358
+ toolCallDelta: toolCallDeltas[i],
359
+ ...(last && finish !== undefined
360
+ ? { finishReason: finish, ...(finish === 'error' ? { error: { code: 'B080001', message: `未映射的 finish_reason "${raw}"` } } : {}) }
361
+ : {}),
362
+ });
363
+ }
364
+ }
365
+ // 1.11-S1(D-33):usage 与事件内容同 chunk 到达(网关共存形态)→ 附着于本事件最后一个 chunk
366
+ if (usage)
367
+ chunks[chunks.length - 1].usage = usage;
368
+ for (const c of chunks)
369
+ yield c;
370
+ }
371
+ }
372
+ }
373
+ export function createOpenAICompatAdapter(opts) {
374
+ const id = opts.id ?? 'openai-compat';
375
+ const baseUrl = opts.baseUrl.replace(/\/+$/, '');
376
+ // 1.4-S4(D-12a,1.2 规划期裁决):TLS 门 1.5-S1 抽取共用(口径与报错文案不变)
377
+ assertTlsOrLoopback(baseUrl);
378
+ const url = `${baseUrl}/chat/completions`;
379
+ // 1.8-BUG-1:缺省 node:http(s)(undici 退出崩溃——面头注记);注入缝保留
380
+ const fetchImpl = opts.fetchImpl ?? nodeHttpSseFetch;
381
+ const sleep = opts.sleep ?? (ms => new Promise(r => setTimeout(r, ms)));
382
+ const timeoutMs = opts.timeoutMs ?? 120_000;
383
+ const firstByteMs = opts.firstByteMs ?? 30_000;
384
+ const retries = opts.retries ?? 2;
385
+ const backoffMs = opts.backoffMs ?? [1_000, 2_000];
386
+ const toOpenAI = (req, apiKey) => ({
387
+ // 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
388
+ // 真 undici 对 GET+body 直接拒绝);car run 真路径接线时暴露(生产调用点核查产出)
389
+ method: 'POST',
390
+ headers: {
391
+ 'content-type': 'application/json',
392
+ authorization: `Bearer ${apiKey}`,
393
+ },
394
+ body: JSON.stringify({
395
+ model: req.model,
396
+ stream: true,
397
+ // 1.11-S1(D-33 / 计量设计 §9-2 推荐A):usage 采集默认开启;第三方网关不兼容时的
398
+ // 回退方案(baseUrl 白名单)登记可回退项——不支持网关走缺失口径(禁估算补造)
399
+ stream_options: { include_usage: true },
400
+ ...(req.maxTokens != null ? { max_tokens: req.maxTokens } : {}),
401
+ messages: req.messages.map(m => {
402
+ if (m.role === 'toolResult') {
403
+ const p = m.content;
404
+ return { role: 'tool', tool_call_id: p?.id, content: p?.error ?? JSON.stringify(p?.result ?? null) };
405
+ }
406
+ if (m.role === 'assistant' && m.content != null && typeof m.content === 'object' && 'toolCall' in m.content) {
407
+ const tc = m.content.toolCall;
408
+ return {
409
+ role: 'assistant',
410
+ content: null,
411
+ tool_calls: [{ id: tc.id, type: 'function', function: { name: tc.tool, arguments: JSON.stringify(tc.args ?? {}) } }],
412
+ };
413
+ }
414
+ return { role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) };
415
+ }),
416
+ // declaredSideEffect 不出站:权限面只进权限门(§3.2.M8.3 字段表约束方向)
417
+ ...(req.tools.length ? { tools: req.tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } })) } : {}),
418
+ }),
419
+ });
420
+ return {
421
+ id,
422
+ chat(req) {
423
+ return streamSsePost({
424
+ url,
425
+ // 凭据门:resolve → reveal(SQ-07 凭据解析;A080001 在此抛出——首块前,runTurn 可 error 收口)
426
+ buildRequest: async () => {
427
+ let apiKey = '';
428
+ if (opts.credentials) {
429
+ const ref = opts.credentials.resolve(opts.provider ?? id, {});
430
+ apiKey = opts.credentials.reveal(ref, {});
431
+ }
432
+ return toOpenAI(req, apiKey);
433
+ },
434
+ fetchImpl,
435
+ signal: req.signal,
436
+ timeoutMs, firstByteMs, retries, backoffMs, sleep,
437
+ }, (body) => parseSseStream(body, req.model, id));
438
+ },
439
+ };
440
+ }
441
+ /** stop_reason → CAR finishReason(未映射 fail-visible——openai FINISH_MAP 同纪律) */
442
+ const ANTHROPIC_STOP_REASON_MAP = {
443
+ end_turn: 'stop',
444
+ stop_sequence: 'stop',
445
+ max_tokens: 'length',
446
+ tool_use: 'toolUse',
447
+ refusal: 'aborted',
448
+ };
449
+ /**
450
+ * 1.11-S1(D-33 / 计量设计 §3.2):anthropic usage 两包合并——message_start(input 侧:
451
+ * input_tokens 天然不含 cache + cache_read/cache_creation)+ message_delta(output 侧:
452
+ * output_tokens)。任一包缺席或主字段非数值 = 侧不完整,整条不落(缺失口径,禁补造半条事实);
453
+ * providerRaw 保留两原始片段供审计对拍。
454
+ */
455
+ function mergeAnthropicUsage(start, delta, model, adapterId) {
456
+ if (!start || !delta)
457
+ return undefined;
458
+ if (typeof start.input_tokens !== 'number' || typeof delta.output_tokens !== 'number')
459
+ return undefined;
460
+ return {
461
+ inputTokens: start.input_tokens,
462
+ outputTokens: delta.output_tokens,
463
+ cacheReadTokens: typeof start.cache_read_input_tokens === 'number' ? start.cache_read_input_tokens : 0,
464
+ cacheCreationTokens: typeof start.cache_creation_input_tokens === 'number' ? start.cache_creation_input_tokens : 0,
465
+ model,
466
+ adapterId,
467
+ providerRaw: { messageStart: start, messageDelta: delta },
468
+ };
469
+ }
470
+ /**
471
+ * Anthropic Messages API 请求体(D-20):system 消息提升为顶层 `system`(多条 \n\n 连接);
472
+ * toolResult 收敛进 user 消息 content(连续多条合并为单 user 多 tool_result 块——Anthropic
473
+ * 消息序列约束);assistant 工具调用 = content:[{type:'tool_use',id,name,input}];
474
+ * declaredSideEffect 不出站(与 openai-compat 同红线)。
475
+ */
476
+ function toAnthropic(req, apiKey) {
477
+ const system = [];
478
+ const turns = [];
479
+ let pendingToolResults = [];
480
+ const flushToolResults = () => {
481
+ if (!pendingToolResults.length)
482
+ return;
483
+ turns.push({ role: 'user', content: pendingToolResults });
484
+ pendingToolResults = [];
485
+ };
486
+ for (const m of req.messages) {
487
+ if (m.role === 'system') {
488
+ system.push(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
489
+ continue;
490
+ }
491
+ if (m.role === 'toolResult') {
492
+ const p = m.content;
493
+ pendingToolResults.push({
494
+ type: 'tool_result',
495
+ tool_use_id: p?.id ?? '',
496
+ content: p?.error ?? JSON.stringify(p?.result ?? null),
497
+ ...(p?.error ? { is_error: true } : {}),
498
+ });
499
+ continue;
500
+ }
501
+ flushToolResults();
502
+ if (m.role === 'assistant' && m.content != null && typeof m.content === 'object' && 'toolCall' in m.content) {
503
+ const tc = m.content.toolCall;
504
+ turns.push({ role: 'assistant', content: [{ type: 'tool_use', id: tc.id, name: tc.tool, input: tc.args ?? {} }] });
505
+ }
506
+ else {
507
+ turns.push({ role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) });
508
+ }
509
+ }
510
+ flushToolResults();
511
+ return {
512
+ method: 'POST',
513
+ headers: {
514
+ 'content-type': 'application/json',
515
+ 'x-api-key': apiKey,
516
+ 'anthropic-version': '2023-06-01',
517
+ },
518
+ body: JSON.stringify({
519
+ model: req.model,
520
+ stream: true,
521
+ max_tokens: req.maxTokens ?? 4096,
522
+ ...(system.length ? { system: system.join('\n\n') } : {}),
523
+ messages: turns,
524
+ ...(req.tools.length
525
+ ? { tools: req.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) }
526
+ : {}),
527
+ }),
528
+ };
529
+ }
530
+ /**
531
+ * Anthropic SSE → LlmChunk 流解析(data-only:data JSON 自带 `type` 字段,`event:` 行不依赖)。
532
+ * content_block_start(tool_use) → toolCallDelta{id,name};input_json_delta → toolCallDelta{argumentsDelta};
533
+ * message_delta.stop_reason → finishReason(终态信号,单次);ping/message_stop/未知事件容错跳过。
534
+ * 1.11-S1(D-33):message_start.usage 采集 input 侧(原解析整体跳过该事件)+ message_delta.usage
535
+ * 合并 output 侧附着于终态 chunk;流中断 delta 未到 = output 侧不完整,整条不落(R-2)。
536
+ */
537
+ async function* parseAnthropicSseStream(body, model, adapterId) {
538
+ const decoder = new TextDecoder();
539
+ let buf = '';
540
+ let startUsage = null;
541
+ for await (const bytes of body) {
542
+ buf += decoder.decode(bytes, { stream: true });
543
+ let nl;
544
+ while ((nl = buf.indexOf('\n')) >= 0) {
545
+ const line = buf.slice(0, nl).replace(/\r$/, '');
546
+ buf = buf.slice(nl + 1);
547
+ if (!line.startsWith('data:'))
548
+ continue;
549
+ const data = line.slice(5).trim();
550
+ if (!data)
551
+ continue;
552
+ let evt;
553
+ try {
554
+ evt = JSON.parse(data);
555
+ }
556
+ catch {
557
+ continue;
558
+ } // 心跳/注释行容错跳过
559
+ const type = evt?.type;
560
+ if (type === 'content_block_start') {
561
+ const block = evt.content_block;
562
+ if (block?.type === 'tool_use') {
563
+ yield { toolCallDelta: { index: evt.index, id: block.id, name: block.name } };
564
+ }
565
+ }
566
+ else if (type === 'content_block_delta') {
567
+ const d = evt.delta;
568
+ if (d?.type === 'text_delta' && typeof d.text === 'string' && d.text.length) {
569
+ yield { delta: d.text };
570
+ }
571
+ else if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string' && d.partial_json.length) {
572
+ yield { toolCallDelta: { index: evt.index, argumentsDelta: d.partial_json } };
573
+ }
574
+ // thinking_delta / citations_delta 等非 CAR 消费面:容错跳过
575
+ }
576
+ else if (type === 'message_start') {
577
+ // 1.11-S1(D-33):input 侧采集(usage 嵌套在 message 下)
578
+ const u = evt.message?.usage;
579
+ if (u && typeof u === 'object')
580
+ startUsage = u;
581
+ }
582
+ else if (type === 'message_delta') {
583
+ const raw = evt.delta?.stop_reason;
584
+ // 1.11-S1(D-33):output 侧(delta.usage.output_tokens)与 input 侧合并——两包齐才落
585
+ const usage = mergeAnthropicUsage(startUsage, (evt.usage ?? null), model, adapterId);
586
+ if (raw != null) {
587
+ const finish = ANTHROPIC_STOP_REASON_MAP[String(raw)];
588
+ const chunk = finish
589
+ ? { finishReason: finish }
590
+ : { finishReason: 'error', error: { code: 'B080001', message: `未映射的 stop_reason "${raw}"` } };
591
+ yield usage ? { ...chunk, usage } : chunk;
592
+ }
593
+ else if (usage) {
594
+ // delta 未携 stop_reason 但携 usage(网关形态):usage 不随缺失终态信号丢失
595
+ yield { usage };
596
+ }
597
+ }
598
+ else if (type === 'error') {
599
+ // 流内 error 事件(overloaded_error 等)→ error chunk 收口(后续 message_delta 由守卫拦冲突)
600
+ yield { finishReason: 'error', error: { code: 'B080001', message: `provider 流错误:${evt.error?.message ?? JSON.stringify(evt).slice(0, 200)}` } };
601
+ }
602
+ }
603
+ }
604
+ }
605
+ export function createAnthropicAdapter(opts) {
606
+ const id = opts.id ?? 'anthropic';
607
+ const baseUrl = opts.baseUrl.replace(/\/+$/, '');
608
+ assertTlsOrLoopback(baseUrl);
609
+ const url = `${baseUrl}/v1/messages`;
610
+ // 1.8-BUG-1:缺省 node:http(s)(undici 退出崩溃——面头注记);注入缝保留
611
+ const fetchImpl = opts.fetchImpl ?? nodeHttpSseFetch;
612
+ const sleep = opts.sleep ?? (ms => new Promise(r => setTimeout(r, ms)));
613
+ const timeoutMs = opts.timeoutMs ?? 120_000;
614
+ const firstByteMs = opts.firstByteMs ?? 30_000;
615
+ const retries = opts.retries ?? 2;
616
+ const backoffMs = opts.backoffMs ?? [1_000, 2_000];
617
+ return {
618
+ id,
619
+ chat(req) {
620
+ return streamSsePost({
621
+ url,
622
+ // 凭据门:resolve → reveal(provider='anthropic' → keychain car-runtime/anthropic →
623
+ // env ANTHROPIC_API_KEY——credentials.ts 1.4 既有登记;A080001 首块前抛出)
624
+ buildRequest: async () => {
625
+ let apiKey = '';
626
+ if (opts.credentials) {
627
+ const ref = opts.credentials.resolve(opts.provider ?? id, {});
628
+ apiKey = opts.credentials.reveal(ref, {});
629
+ }
630
+ return toAnthropic(req, apiKey);
631
+ },
632
+ fetchImpl,
633
+ signal: req.signal,
634
+ timeoutMs, firstByteMs, retries, backoffMs, sleep,
635
+ }, (body) => parseAnthropicSseStream(body, req.model, id));
636
+ },
637
+ };
638
+ }
@@ -0,0 +1,39 @@
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 { type ScanHit } from '../security/secrets.ts';
15
+ export interface RedactionResult {
16
+ /** 脱敏后文本(§3.2.M8.3 字段表口径:RedactionResult.redacted) */
17
+ redacted: string;
18
+ /** 命中遮蔽次数 */
19
+ redactedCount: number;
20
+ /** 命中明细(审计留痕用;不含原文) */
21
+ hits: Array<Pick<ScanHit, 'category' | 'name' | 'start' | 'end' | 'masked' | 'score'>>;
22
+ }
23
+ /** 非流式脱敏钩子(§3.2.M8.2:redact(text): RedactionResult,天然幂等) */
24
+ export declare function redact(text: string): RedactionResult;
25
+ /**
26
+ * 流式脱敏器:push() 返回「当前可安全放行」的文本(已遮蔽),flush() 收口全量扫描。
27
+ * 语义:不泄漏(任何已放行字节不可能成为命中的一部分)、不误遮(锚点未补齐成命中时
28
+ * 原文放行)、粒度换正确性(疑似锚点驻留直到命中补齐或 flush——长行含假锚点时
29
+ * 流式粒度退化到收口时点,登记为已接受折衷)。
30
+ */
31
+ export declare class StreamRedactor {
32
+ #private;
33
+ /** 追加 delta,返回可安全放行的文本(可能为空串) */
34
+ push(text: string): string;
35
+ /** 收口(finishReason 时点):全量扫描遮蔽剩余驻留 */
36
+ flush(): string;
37
+ /** 已遮蔽计数口径(测试/审计观察面):flush 前的累计放行不含命中原文 */
38
+ get pendingLength(): number;
39
+ }