@lqc123qwe/car-runtime 1.3.0 → 1.5.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/.github/workflows/ci.yml +12 -0
- package/README.md +19 -3
- package/package.json +4 -1
- package/src/cli.ts +178 -15
- package/src/dx/doctor.ts +52 -3
- package/src/load/config.ts +247 -63
- package/src/load/loader.ts +21 -1
- package/src/loop/stop.ts +12 -1
- package/src/mcp/httpClient.ts +176 -0
- package/src/mcp/stdioClient.ts +75 -0
- package/src/ptc/runCode.ts +10 -0
- package/src/runtime-core/chatStep.ts +19 -5
- package/src/runtime-core/credentials.ts +6 -1
- package/src/runtime-core/llm.ts +291 -61
- package/src/runtime-core/telemetry.ts +42 -6
- package/src/runtime-core/tools.ts +27 -0
- package/src/runtime-core/trace.ts +63 -0
- package/src/runtime-core/types.ts +4 -1
- package/src/session/log.ts +3 -2
- package/test/contract/eventmap.golden.json +1 -1
- package/test/s26-signature-closeout.spec.ts +1 -1
- package/test/s28-session-index-followup.spec.ts +1 -1
- package/test/s31-real-model-wiring.spec.ts +329 -0
- package/test/s32-telemetry-mcp-ptc.spec.ts +218 -0
- package/test/s33-anthropic-adapter.spec.ts +354 -0
- package/test/s34-mcp-remote-transport.spec.ts +250 -0
- package/test/s35-config-ts-zod.spec.ts +145 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.4-S7 · MCP stdio 客户端传输(W1 MCP 工具进 turn):spawn 子进程 + JSON-RPC 换行分隔 JSON
|
|
3
|
+
*
|
|
4
|
+
* 口径(1.4 规划 W1 MCP 块 / D-18):
|
|
5
|
+
* - CAR → 外部 MCP Server 方向(gateway.ts ClientTransport 的真实通道实装——此前仅测试替身);
|
|
6
|
+
* server 侧对偶(宿主 → CAR)见 src/host/stdio.ts;
|
|
7
|
+
* - 行协议:请求一行 JSON 写入 server stdin;响应按 id 匹配 pending 表(行解析容错跳过非 JSON 行);
|
|
8
|
+
* - send 内建 30s 超时(fail-visible——gateway.register 的 tools/list 无外层 race,防挂死);
|
|
9
|
+
* - stderr 不透传(server 诊断输出与协议通道隔离);close = kill(car run 收口调用)。
|
|
10
|
+
*/
|
|
11
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
12
|
+
import type { ClientTransport, JsonRpcResponse } from './gateway.ts'
|
|
13
|
+
|
|
14
|
+
export interface StdioServerSpec {
|
|
15
|
+
command: string
|
|
16
|
+
args?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createStdioClientTransport(spec: StdioServerSpec): ClientTransport & { readonly alive: () => boolean } {
|
|
20
|
+
const child: ChildProcess = spawn(spec.command, spec.args ?? [], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
21
|
+
const pending = new Map<number, (res: JsonRpcResponse) => void>()
|
|
22
|
+
let buffer = ''
|
|
23
|
+
let exited = false
|
|
24
|
+
child.on('exit', () => { exited = true })
|
|
25
|
+
child.stdout!.on('data', chunk => {
|
|
26
|
+
buffer += chunk.toString('utf-8')
|
|
27
|
+
let nl: number
|
|
28
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
29
|
+
const line = buffer.slice(0, nl).trim()
|
|
30
|
+
buffer = buffer.slice(nl + 1)
|
|
31
|
+
if (!line) continue
|
|
32
|
+
try {
|
|
33
|
+
const res = JSON.parse(line) as JsonRpcResponse
|
|
34
|
+
const settle = pending.get(res.id as number)
|
|
35
|
+
if (settle) {
|
|
36
|
+
pending.delete(res.id as number)
|
|
37
|
+
settle(res)
|
|
38
|
+
}
|
|
39
|
+
} catch { /* 非 JSON 行容错跳过(server 诊断污染 stdin 协议通道时不断链) */ }
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
child.stderr!.on('data', () => { /* 隔离(见头注释) */ })
|
|
43
|
+
child.on('error', () => { exited = true })
|
|
44
|
+
|
|
45
|
+
const transport: ClientTransport & { readonly alive: () => boolean } = {
|
|
46
|
+
send(req) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
if (!transport.alive()) {
|
|
49
|
+
reject(new Error('CAR-E-MCP: stdio transport not alive(server 进程已退出)'))
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
pending.delete(req.id as number)
|
|
54
|
+
reject(new Error('CAR-E-MCP: stdio send timeout (30s)'))
|
|
55
|
+
}, 30_000)
|
|
56
|
+
pending.set(req.id as number, res => {
|
|
57
|
+
clearTimeout(timer)
|
|
58
|
+
resolve(res)
|
|
59
|
+
})
|
|
60
|
+
try {
|
|
61
|
+
child.stdin!.write(JSON.stringify(req) + '\n')
|
|
62
|
+
} catch (e) {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
pending.delete(req.id as number)
|
|
65
|
+
reject(e)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
alive: () => !exited && child.exitCode === null && !child.killed,
|
|
70
|
+
close: () => {
|
|
71
|
+
try { child.kill() } catch { /* 已退出 */ }
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
return transport
|
|
75
|
+
}
|
package/src/ptc/runCode.ts
CHANGED
|
@@ -116,6 +116,16 @@ export function makePtcToolDefinition(opts: { tools: Map<string, ToolBridge>; au
|
|
|
116
116
|
return {
|
|
117
117
|
name: 'run_code',
|
|
118
118
|
description: '执行一段 erasable TypeScript 程序(PTC)——程序体内经 tools.<name>(args) 调用已注册工具',
|
|
119
|
+
// 1.4-S7:run_code 入参 schema(进模型声明面——此前无 parameters,模型看不到 code/description 形状)
|
|
120
|
+
parameters: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
code: { type: 'string', description: 'erasable TS 程序体(async 函数体;经 tools.<name>(args) 调用已注册工具)' },
|
|
124
|
+
description: { type: 'string', description: '本段程序的一句话描述(审计留痕)' },
|
|
125
|
+
},
|
|
126
|
+
required: ['code', 'description'],
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
},
|
|
119
129
|
// T-22 语义:PTC 与 bash 同信任级别 → 强制 write 最高约束(不可声明为 readonly)
|
|
120
130
|
declaredSideEffect: 'write' as const,
|
|
121
131
|
run: async (args: { code: string; description: string; toolCallId?: string }) => {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* 由 runTurn 落 turnEnd);工具调用事件由 runTurn executeBatch 落(本层不重复)。
|
|
16
16
|
*/
|
|
17
17
|
import type { SessionLog } from '../session/log.ts'
|
|
18
|
-
import type
|
|
18
|
+
import { TurnAborted, type ModelStep, type ToolCall } from '../loop/stop.ts'
|
|
19
19
|
import type { RuntimeCore } from './llm.ts'
|
|
20
20
|
import type { ToolDefinition } from './types.ts'
|
|
21
21
|
import { StreamRedactor } from './redaction.ts'
|
|
@@ -32,6 +32,8 @@ export interface ChatStepOptions {
|
|
|
32
32
|
tools: ToolDefinition[]
|
|
33
33
|
maxTokens?: number
|
|
34
34
|
adapterId?: string
|
|
35
|
+
/** 1.4-S4(D-19):turn 取消信号——chunk 间隙与流结束后(finish 检查前)检查,命中抛 TurnAborted */
|
|
36
|
+
signal?: { aborted: boolean }
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/** ModelStep 加法扩展:脱敏计数留痕(S15 secretsRedacted 同名口径) */
|
|
@@ -46,11 +48,17 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
|
|
|
46
48
|
throw new Error(`CAR-E-N1: 模型请求前快照与日志投影失配 @ atSeq=${asserted.failedAt}(Model-visible means logged 违规)`)
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
// 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest
|
|
50
|
-
//
|
|
51
|
+
// 2. 请求消息 = 投影(角色系统:user/assistant/toolResult 与 LlmRequest 对齐);
|
|
52
|
+
// toolCall 事件投影形态为 { role:'assistant', toolCall }(log.ts 口径)——此处归一化为
|
|
53
|
+
// content {toolCall} 载荷(适配器历史回路映射的唯一消费源)。
|
|
54
|
+
// 1.5-BUG-1:此前仅透传 m.content,toolCall 属性被丢弃——工具调用历史在后续请求
|
|
55
|
+
// 静默消失(真实 provider 对无 tool_calls 前驱的 tool 消息直接 400);anthropic E2E
|
|
56
|
+
// 生产调用点核查(1.5-GO-5)暴露,openai-compat 同样受益。
|
|
51
57
|
const messages = log.deriveMessages().map(m => ({
|
|
52
|
-
role: m.role as 'user' | 'assistant' | 'toolResult',
|
|
53
|
-
content: m.
|
|
58
|
+
role: m.role as 'system' | 'user' | 'assistant' | 'toolResult',
|
|
59
|
+
content: (m as unknown as { toolCall?: unknown }).toolCall !== undefined
|
|
60
|
+
? { toolCall: (m as unknown as { toolCall: unknown }).toolCall }
|
|
61
|
+
: m.content,
|
|
54
62
|
}))
|
|
55
63
|
|
|
56
64
|
// 4. 流式消费(3. 凭据解析在适配器首块前,A080001 由此传播)
|
|
@@ -66,9 +74,12 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
|
|
|
66
74
|
tools,
|
|
67
75
|
...(maxTokens != null ? { maxTokens } : {}),
|
|
68
76
|
...(adapterId ? { adapterId } : {}),
|
|
77
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
69
78
|
...(opts.log.sessionId ? { metadata: { sessionId: opts.log.sessionId, turnId, traceId: `${opts.log.sessionId}:${turnId}` } } : {}),
|
|
70
79
|
})
|
|
71
80
|
for await (const chunk of stream) {
|
|
81
|
+
// 1.4-S4(D-19):chunk 间隙取消——此刻 assistant 尚未落链(流完才 append),无半截消息
|
|
82
|
+
if (opts.signal?.aborted) throw new TurnAborted()
|
|
72
83
|
if (chunk.delta !== undefined) text += redactor.push(chunk.delta)
|
|
73
84
|
if (chunk.toolCallDelta) {
|
|
74
85
|
const idx = chunk.toolCallDelta.index ?? 0
|
|
@@ -81,6 +92,9 @@ export async function chatStep(opts: ChatStepOptions): Promise<ChatStepResult> {
|
|
|
81
92
|
if (chunk.error) errorDetail = chunk.error.message
|
|
82
93
|
if (chunk.finishReason !== undefined) stopReason = chunk.finishReason === 'aborted' ? 'error' : chunk.finishReason
|
|
83
94
|
}
|
|
95
|
+
// 1.4-S4(D-19):finish 检查前先查取消(适配器取消路径静默收口无 finishReason——
|
|
96
|
+
// 必须先于此处的契约兜底,否则取消被误判为适配器违规)
|
|
97
|
+
if (opts.signal?.aborted) throw new TurnAborted()
|
|
84
98
|
if (stopReason === undefined) {
|
|
85
99
|
// 适配器层已保证首 finishReason(AL-05);防御性兜底显式化
|
|
86
100
|
throw new Error('CAR-E-LLM-FINISH: 流结束无 finishReason(适配器契约违规)')
|
|
@@ -37,6 +37,11 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
|
|
37
37
|
}
|
|
38
38
|
const GENERIC_ENV_VARS = ['CAR_LLM_API_KEY']
|
|
39
39
|
|
|
40
|
+
/** provider 候选 env 变量(doctor 存在性探测等诊断面复用;值永不打印) */
|
|
41
|
+
export function providerEnvVars(provider: string): string[] {
|
|
42
|
+
return [...(PROVIDER_ENV_VARS[provider] ?? []), ...GENERIC_ENV_VARS]
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
/** keychain 服务名(命名空间隔离:car-runtime/<provider>) */
|
|
41
46
|
export function keychainService(provider: string): string {
|
|
42
47
|
return `car-runtime/${provider}`
|
|
@@ -102,7 +107,7 @@ export class CredentialService {
|
|
|
102
107
|
|
|
103
108
|
const allowEnv = opts.allowEnvFallback === true || env.CAR_ALLOW_ENV_CREDENTIALS === '1'
|
|
104
109
|
if (allowEnv) {
|
|
105
|
-
const candidates =
|
|
110
|
+
const candidates = providerEnvVars(provider)
|
|
106
111
|
for (const name of candidates) {
|
|
107
112
|
searched.push(`env:${name}`)
|
|
108
113
|
if (env[name]) {
|
package/src/runtime-core/llm.ts
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* - 重试基线(§3.5.4):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;**首块前失败才可重试**
|
|
10
10
|
* (已消费的 chunk 不可重放——重试不产生额外副作用);重试耗尽 = CarM8Error B080001
|
|
11
11
|
* (BD-04 error 收口由消费方 runTurn 落 turnEnd);流中途失败 = finishReason 'error' chunk;
|
|
12
|
-
* - 超时基线:总 120s / 首字节 30s(均可注入缩短供测试);TLS 强制(https-only
|
|
13
|
-
*
|
|
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')。
|
|
14
18
|
*/
|
|
15
19
|
import { CarM8Error, providerUnreachable } from './errors.ts'
|
|
16
20
|
import { CredentialService } from './credentials.ts'
|
|
@@ -107,6 +111,101 @@ export class RuntimeCore {
|
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
// ==================== 适配器共用基线(1.5-S1 抽取:D-12a TLS 门 + §3.5.4 流式引擎) ====================
|
|
115
|
+
|
|
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: string): void {
|
|
122
|
+
if (baseUrl.startsWith('https://')) return
|
|
123
|
+
let loopback = false
|
|
124
|
+
try { const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, ''); loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' } catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
|
|
125
|
+
if (!loopback) {
|
|
126
|
+
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 代理或自建适配器`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 流式 POST 请求规格(凭据解析在 buildRequest 内——A080001 首块前抛出,runTurn 可 error 收口) */
|
|
131
|
+
interface SsePostSpec {
|
|
132
|
+
url: string
|
|
133
|
+
buildRequest: () => Promise<{ method: 'POST'; headers: Record<string, string>; body: string }>
|
|
134
|
+
fetchImpl: typeof fetch
|
|
135
|
+
signal?: { aborted: boolean }
|
|
136
|
+
timeoutMs: number
|
|
137
|
+
firstByteMs: number
|
|
138
|
+
retries: number
|
|
139
|
+
backoffMs: number[]
|
|
140
|
+
sleep: (ms: number) => Promise<void>
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* §3.5.4 流式 POST 引擎(openai-compat / anthropic 单点共用,1.5-S1 抽取——两适配器重试/
|
|
145
|
+
* 超时/取消语义不漂移):仅网络错误/429/5xx、≤2 次、指数退避 1s/2s;首块前失败才可重试
|
|
146
|
+
* (已消费 chunk 不可重放);重试耗尽 = CarM8Error B080001;外部取消 ≠ 失败(D-19:静默
|
|
147
|
+
* 收口不 retry 不 error chunk);流自然结束未携带 finishReason = AL-05 显式 error chunk。
|
|
148
|
+
* `parse` 为唯一 provider 差异点(SSE 字节流 → LlmChunk)。
|
|
149
|
+
*/
|
|
150
|
+
async function* streamSsePost(spec: SsePostSpec, parse: (body: AsyncIterable<Uint8Array>) => AsyncIterable<LlmChunk>): AsyncIterable<LlmChunk> {
|
|
151
|
+
const payload = await spec.buildRequest()
|
|
152
|
+
for (let attempt = 0; ; attempt++) {
|
|
153
|
+
// D-19:外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
|
|
154
|
+
if (spec.signal?.aborted) return
|
|
155
|
+
const controller = new AbortController()
|
|
156
|
+
const started = Date.now()
|
|
157
|
+
let firstByteSeen = false
|
|
158
|
+
const firstByteTimer = setTimeout(() => controller.abort(), spec.firstByteMs)
|
|
159
|
+
const overallTimer = setTimeout(() => controller.abort(), spec.timeoutMs)
|
|
160
|
+
try {
|
|
161
|
+
const res = await spec.fetchImpl(spec.url, { ...payload, signal: controller.signal })
|
|
162
|
+
if (res.status === 429 || res.status >= 500) throw new Retryable(`HTTP ${res.status}`)
|
|
163
|
+
if (!res.ok) {
|
|
164
|
+
const text = await res.text().catch(() => '')
|
|
165
|
+
// 4xx 业务错不重试(§3.5.4);错误信息不含凭据(凭据值不进错误路径)
|
|
166
|
+
yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } }
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (!res.body) throw new Retryable('响应无 body 流')
|
|
170
|
+
let sawFinish = false
|
|
171
|
+
for await (const chunk of parse(res.body)) {
|
|
172
|
+
// D-19:chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,不违反
|
|
173
|
+
// finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
|
|
174
|
+
if (spec.signal?.aborted) { controller.abort(); return }
|
|
175
|
+
if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
|
|
176
|
+
if (chunk.finishReason !== undefined) sawFinish = true
|
|
177
|
+
yield chunk
|
|
178
|
+
}
|
|
179
|
+
// 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
|
|
180
|
+
// 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1 口径延续)
|
|
181
|
+
if (!sawFinish) {
|
|
182
|
+
yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } }
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// D-19:外部取消(abort 连锁的 fetch 异常)≠ 失败——静默收口,不走 retry
|
|
187
|
+
if (spec.signal?.aborted) return
|
|
188
|
+
const beforeFirstByte = !firstByteSeen
|
|
189
|
+
clearTimeout(firstByteTimer)
|
|
190
|
+
// 首块前失败 = 网络错误/429/5xx/首字节超时(§3.5.4 重试条件全集)——未消费任何 chunk
|
|
191
|
+
if (beforeFirstByte && attempt < spec.retries) {
|
|
192
|
+
await spec.sleep(spec.backoffMs[Math.min(attempt, spec.backoffMs.length - 1)]!)
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (beforeFirstByte) {
|
|
196
|
+
// 重试耗尽 → B080001(BD-04:消费方 runTurn 以 error 收口)
|
|
197
|
+
throw providerUnreachable(`${(e as Error).name}: ${(e as Error).message}(尝试 ${attempt + 1}/${spec.retries + 1},${Date.now() - started}ms)`)
|
|
198
|
+
}
|
|
199
|
+
// 流中途失败:已消费 chunk 不可重放 → error chunk 收口
|
|
200
|
+
yield { finishReason: 'error', error: { code: 'B080001', message: `流中途失败:${(e as Error).message}` } }
|
|
201
|
+
return
|
|
202
|
+
} finally {
|
|
203
|
+
clearTimeout(firstByteTimer)
|
|
204
|
+
clearTimeout(overallTimer)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
110
209
|
// ==================== 内置 openai-compat 适配器(流式 SSE) ====================
|
|
111
210
|
|
|
112
211
|
export interface OpenAICompatOptions {
|
|
@@ -198,9 +297,8 @@ async function* parseSseStream(body: AsyncIterable<Uint8Array>): AsyncIterable<L
|
|
|
198
297
|
export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter & { id: string } {
|
|
199
298
|
const id = opts.id ?? 'openai-compat'
|
|
200
299
|
const baseUrl = opts.baseUrl.replace(/\/+$/, '')
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
300
|
+
// 1.4-S4(D-12a,1.2 规划期裁决):TLS 门 1.5-S1 抽取共用(口径与报错文案不变)
|
|
301
|
+
assertTlsOrLoopback(baseUrl)
|
|
204
302
|
const url = `${baseUrl}/chat/completions`
|
|
205
303
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
206
304
|
const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
@@ -210,6 +308,9 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
|
|
|
210
308
|
const backoffMs = opts.backoffMs ?? [1_000, 2_000]
|
|
211
309
|
|
|
212
310
|
const toOpenAI = (req: LlmRequest, apiKey: string) => ({
|
|
311
|
+
// 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
|
|
312
|
+
// 真 undici 对 GET+body 直接拒绝);car run 真路径接线时暴露(生产调用点核查产出)
|
|
313
|
+
method: 'POST' as const,
|
|
213
314
|
headers: {
|
|
214
315
|
'content-type': 'application/json',
|
|
215
316
|
authorization: `Bearer ${apiKey}`,
|
|
@@ -240,65 +341,194 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
|
|
|
240
341
|
|
|
241
342
|
return {
|
|
242
343
|
id,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
344
|
+
chat(req: LlmRequest): AsyncIterable<LlmChunk> {
|
|
345
|
+
return streamSsePost({
|
|
346
|
+
url,
|
|
347
|
+
// 凭据门:resolve → reveal(SQ-07 凭据解析;A080001 在此抛出——首块前,runTurn 可 error 收口)
|
|
348
|
+
buildRequest: async () => {
|
|
349
|
+
let apiKey = ''
|
|
350
|
+
if (opts.credentials) {
|
|
351
|
+
const ref = opts.credentials.resolve(opts.provider ?? id, {})
|
|
352
|
+
apiKey = opts.credentials.reveal(ref, {})
|
|
289
353
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
354
|
+
return toOpenAI(req, apiKey)
|
|
355
|
+
},
|
|
356
|
+
fetchImpl,
|
|
357
|
+
signal: req.signal,
|
|
358
|
+
timeoutMs, firstByteMs, retries, backoffMs, sleep,
|
|
359
|
+
}, parseSseStream)
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ==================== 内置 anthropic 适配器(Messages API 流式 SSE,1.5-S1 / D-20) ====================
|
|
365
|
+
|
|
366
|
+
export interface AnthropicAdapterOptions {
|
|
367
|
+
id?: string
|
|
368
|
+
/** API 源(如 https://api.anthropic.com;请求路径 = {baseUrl}/v1/messages,与官方 SDK base_url 同约定) */
|
|
369
|
+
baseUrl: string
|
|
370
|
+
/** 凭据服务与 provider 名(x-api-key 来源;provider 缺省 'anthropic' → ANTHROPIC_API_KEY) */
|
|
371
|
+
credentials?: CredentialService
|
|
372
|
+
provider?: string
|
|
373
|
+
fetchImpl?: typeof fetch
|
|
374
|
+
/** §3.5.4 基线:总 120s / 首字节 30s / 2 次退避 1s-2s(与 openai-compat 同款) */
|
|
375
|
+
timeoutMs?: number
|
|
376
|
+
firstByteMs?: number
|
|
377
|
+
retries?: number
|
|
378
|
+
backoffMs?: number[]
|
|
379
|
+
sleep?: (ms: number) => Promise<void>
|
|
380
|
+
/** D-20:Anthropic API max_tokens 必填——req.maxTokens 缺席时取此缺省(provider 差异登记,缺省 4096) */
|
|
381
|
+
defaultMaxTokens?: number
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** stop_reason → CAR finishReason(未映射 fail-visible——openai FINISH_MAP 同纪律) */
|
|
385
|
+
const ANTHROPIC_STOP_REASON_MAP: Record<string, LlmChunk['finishReason']> = {
|
|
386
|
+
end_turn: 'stop',
|
|
387
|
+
stop_sequence: 'stop',
|
|
388
|
+
max_tokens: 'length',
|
|
389
|
+
tool_use: 'toolUse',
|
|
390
|
+
refusal: 'aborted',
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Anthropic Messages API 请求体(D-20):system 消息提升为顶层 `system`(多条 \n\n 连接);
|
|
395
|
+
* toolResult 收敛进 user 消息 content(连续多条合并为单 user 多 tool_result 块——Anthropic
|
|
396
|
+
* 消息序列约束);assistant 工具调用 = content:[{type:'tool_use',id,name,input}];
|
|
397
|
+
* declaredSideEffect 不出站(与 openai-compat 同红线)。
|
|
398
|
+
*/
|
|
399
|
+
function toAnthropic(req: LlmRequest, apiKey: string) {
|
|
400
|
+
const system: string[] = []
|
|
401
|
+
const turns: Array<{ role: 'user' | 'assistant'; content: unknown }> = []
|
|
402
|
+
let pendingToolResults: Array<Record<string, unknown>> = []
|
|
403
|
+
const flushToolResults = () => {
|
|
404
|
+
if (!pendingToolResults.length) return
|
|
405
|
+
turns.push({ role: 'user', content: pendingToolResults })
|
|
406
|
+
pendingToolResults = []
|
|
407
|
+
}
|
|
408
|
+
for (const m of req.messages) {
|
|
409
|
+
if (m.role === 'system') {
|
|
410
|
+
system.push(typeof m.content === 'string' ? m.content : JSON.stringify(m.content))
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (m.role === 'toolResult') {
|
|
414
|
+
const p = m.content as { id?: string; result?: unknown; error?: string }
|
|
415
|
+
pendingToolResults.push({
|
|
416
|
+
type: 'tool_result',
|
|
417
|
+
tool_use_id: p?.id ?? '',
|
|
418
|
+
content: p?.error ?? JSON.stringify(p?.result ?? null),
|
|
419
|
+
...(p?.error ? { is_error: true } : {}),
|
|
420
|
+
})
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
flushToolResults()
|
|
424
|
+
if (m.role === 'assistant' && m.content != null && typeof m.content === 'object' && 'toolCall' in (m.content as object)) {
|
|
425
|
+
const tc = (m.content as { toolCall: { id: string; tool: string; args: unknown } }).toolCall
|
|
426
|
+
turns.push({ role: 'assistant', content: [{ type: 'tool_use', id: tc.id, name: tc.tool, input: tc.args ?? {} }] })
|
|
427
|
+
} else {
|
|
428
|
+
turns.push({ role: m.role, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) })
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
flushToolResults()
|
|
432
|
+
return {
|
|
433
|
+
method: 'POST' as const,
|
|
434
|
+
headers: {
|
|
435
|
+
'content-type': 'application/json',
|
|
436
|
+
'x-api-key': apiKey,
|
|
437
|
+
'anthropic-version': '2023-06-01',
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
model: req.model,
|
|
441
|
+
stream: true,
|
|
442
|
+
max_tokens: req.maxTokens ?? 4096,
|
|
443
|
+
...(system.length ? { system: system.join('\n\n') } : {}),
|
|
444
|
+
messages: turns,
|
|
445
|
+
...(req.tools.length
|
|
446
|
+
? { tools: req.tools.map(t => ({ name: t.name, description: t.description, input_schema: t.parameters })) }
|
|
447
|
+
: {}),
|
|
448
|
+
}),
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Anthropic SSE → LlmChunk 流解析(data-only:data JSON 自带 `type` 字段,`event:` 行不依赖)。
|
|
454
|
+
* content_block_start(tool_use) → toolCallDelta{id,name};input_json_delta → toolCallDelta{argumentsDelta};
|
|
455
|
+
* message_delta.stop_reason → finishReason(终态信号,单次);ping/message_stop/未知事件容错跳过。
|
|
456
|
+
*/
|
|
457
|
+
async function* parseAnthropicSseStream(body: AsyncIterable<Uint8Array>): AsyncIterable<LlmChunk> {
|
|
458
|
+
const decoder = new TextDecoder()
|
|
459
|
+
let buf = ''
|
|
460
|
+
for await (const bytes of body) {
|
|
461
|
+
buf += decoder.decode(bytes, { stream: true })
|
|
462
|
+
let nl: number
|
|
463
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
464
|
+
const line = buf.slice(0, nl).replace(/\r$/, '')
|
|
465
|
+
buf = buf.slice(nl + 1)
|
|
466
|
+
if (!line.startsWith('data:')) continue
|
|
467
|
+
const data = line.slice(5).trim()
|
|
468
|
+
if (!data) continue
|
|
469
|
+
let evt: any
|
|
470
|
+
try { evt = JSON.parse(data) } catch { continue } // 心跳/注释行容错跳过
|
|
471
|
+
const type = evt?.type
|
|
472
|
+
if (type === 'content_block_start') {
|
|
473
|
+
const block = evt.content_block
|
|
474
|
+
if (block?.type === 'tool_use') {
|
|
475
|
+
yield { toolCallDelta: { index: evt.index, id: block.id, name: block.name } }
|
|
300
476
|
}
|
|
477
|
+
} else if (type === 'content_block_delta') {
|
|
478
|
+
const d = evt.delta
|
|
479
|
+
if (d?.type === 'text_delta' && typeof d.text === 'string' && d.text.length) {
|
|
480
|
+
yield { delta: d.text }
|
|
481
|
+
} else if (d?.type === 'input_json_delta' && typeof d.partial_json === 'string' && d.partial_json.length) {
|
|
482
|
+
yield { toolCallDelta: { index: evt.index, argumentsDelta: d.partial_json } }
|
|
483
|
+
}
|
|
484
|
+
// thinking_delta / citations_delta 等非 CAR 消费面:容错跳过
|
|
485
|
+
} else if (type === 'message_delta') {
|
|
486
|
+
const raw = evt.delta?.stop_reason
|
|
487
|
+
if (raw != null) {
|
|
488
|
+
const finish = ANTHROPIC_STOP_REASON_MAP[String(raw)]
|
|
489
|
+
yield finish
|
|
490
|
+
? { finishReason: finish }
|
|
491
|
+
: { finishReason: 'error', error: { code: 'B080001', message: `未映射的 stop_reason "${raw}"` } }
|
|
492
|
+
}
|
|
493
|
+
} else if (type === 'error') {
|
|
494
|
+
// 流内 error 事件(overloaded_error 等)→ error chunk 收口(后续 message_delta 由守卫拦冲突)
|
|
495
|
+
yield { finishReason: 'error', error: { code: 'B080001', message: `provider 流错误:${evt.error?.message ?? JSON.stringify(evt).slice(0, 200)}` } }
|
|
301
496
|
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function createAnthropicAdapter(opts: AnthropicAdapterOptions): LlmAdapter & { id: string } {
|
|
502
|
+
const id = opts.id ?? 'anthropic'
|
|
503
|
+
const baseUrl = opts.baseUrl.replace(/\/+$/, '')
|
|
504
|
+
assertTlsOrLoopback(baseUrl)
|
|
505
|
+
const url = `${baseUrl}/v1/messages`
|
|
506
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
507
|
+
const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
508
|
+
const timeoutMs = opts.timeoutMs ?? 120_000
|
|
509
|
+
const firstByteMs = opts.firstByteMs ?? 30_000
|
|
510
|
+
const retries = opts.retries ?? 2
|
|
511
|
+
const backoffMs = opts.backoffMs ?? [1_000, 2_000]
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
id,
|
|
515
|
+
chat(req: LlmRequest): AsyncIterable<LlmChunk> {
|
|
516
|
+
return streamSsePost({
|
|
517
|
+
url,
|
|
518
|
+
// 凭据门:resolve → reveal(provider='anthropic' → keychain car-runtime/anthropic →
|
|
519
|
+
// env ANTHROPIC_API_KEY——credentials.ts 1.4 既有登记;A080001 首块前抛出)
|
|
520
|
+
buildRequest: async () => {
|
|
521
|
+
let apiKey = ''
|
|
522
|
+
if (opts.credentials) {
|
|
523
|
+
const ref = opts.credentials.resolve(opts.provider ?? id, {})
|
|
524
|
+
apiKey = opts.credentials.reveal(ref, {})
|
|
525
|
+
}
|
|
526
|
+
return toAnthropic(req, apiKey)
|
|
527
|
+
},
|
|
528
|
+
fetchImpl,
|
|
529
|
+
signal: req.signal,
|
|
530
|
+
timeoutMs, firstByteMs, retries, backoffMs, sleep,
|
|
531
|
+
}, parseAnthropicSseStream)
|
|
302
532
|
},
|
|
303
533
|
}
|
|
304
534
|
}
|