@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,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.4-S6~S7 · 遥测 trace 面 + MCP 工具进 turn + PTC 进真实 turn(S32)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.4-迭代规划 OTel 块 / MCP 块 / PTC 块):
|
|
5
|
+
* - telemetry trace 透传:traceId 复用 + parentSpanId 挂链(OTLP 出站体断言);缺省行为不变
|
|
6
|
+
* - TurnTracer:turn(根)→ step(子)层级;noop 门面零出站
|
|
7
|
+
* - 生产调用点:car run spawn 全链——CAR_OTEL_ENDPOINT 捕获 OTLP traces(共享 traceId + 父子链);
|
|
8
|
+
* --mcp(config mcp.servers + 受控 stdio 假 server)→ MCP 工具进声明面 + callTool 真派发;
|
|
9
|
+
* --ptc → run_code 参数 schema 出站 + worker 桥真调工具 + renderSdk 声明块进 system
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { createServer, type Server } from 'node:http'
|
|
15
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join, dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { createTelemetryFacade, type TelemetrySpan } from '../src/runtime-core/telemetry.ts'
|
|
20
|
+
import { TurnTracer } from '../src/runtime-core/trace.ts'
|
|
21
|
+
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
23
|
+
const CLI = join(ROOT, 'src', 'cli.ts')
|
|
24
|
+
|
|
25
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
26
|
+
|
|
27
|
+
// 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
|
|
28
|
+
const TEST_KEY = 'test-key-123'
|
|
29
|
+
|
|
30
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s32-${name}-`))
|
|
32
|
+
try {
|
|
33
|
+
const r = fn(dir)
|
|
34
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
35
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
36
|
+
cleanup()
|
|
37
|
+
return Promise.resolve()
|
|
38
|
+
} catch (e) {
|
|
39
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
40
|
+
throw e
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
|
|
45
|
+
|
|
46
|
+
// ==================== trace 透传 + TurnTracer(单元) ====================
|
|
47
|
+
|
|
48
|
+
test('S32: telemetry trace 透传——traceId 复用 + parentSpanId 进 OTLP 出站体;缺省行为不变', async () => {
|
|
49
|
+
await withTempDir('trace-unit', async dir => {
|
|
50
|
+
const bodies: any[] = []
|
|
51
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
52
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
53
|
+
})
|
|
54
|
+
// 子 span:显式 trace(traceId 复用 + 父 spanId)
|
|
55
|
+
const child = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 }, trace: { traceId: 'a'.repeat(32), parentSpanId: 'b'.repeat(16) } })
|
|
56
|
+
assert.equal(child.traceId, 'a'.repeat(32), '显式 traceId 复用')
|
|
57
|
+
assert.equal(child.spanId.length, 16, 'spanId 生成')
|
|
58
|
+
child.end()
|
|
59
|
+
// 兄弟 span:同 traceId、无 parent
|
|
60
|
+
const sibling = f.getTracer().startSpan('car.step', { trace: { traceId: 'a'.repeat(32) } })
|
|
61
|
+
sibling.end()
|
|
62
|
+
// 独立 span:缺省随机 traceId(1.1 行为不变)
|
|
63
|
+
const standalone: TelemetrySpan = f.getTracer().startSpan('standalone')
|
|
64
|
+
assert.notEqual(standalone.traceId, 'a'.repeat(32))
|
|
65
|
+
standalone.end()
|
|
66
|
+
await f.flush()
|
|
67
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
68
|
+
const c = spans.find((s: any) => s.name === 'car.step' && s.parentSpanId === 'b'.repeat(16))
|
|
69
|
+
assert.ok(c, 'parentSpanId 进出站体')
|
|
70
|
+
assert.equal(c.traceId, 'a'.repeat(32))
|
|
71
|
+
const sib = spans.find((s: any) => s.name === 'car.step' && !s.parentSpanId)
|
|
72
|
+
assert.ok(sib, '同 traceId 无父的兄弟 span')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('S32: TurnTracer——turn(根)→ step(子)层级共享 traceId;noop 门面零出站', async () => {
|
|
77
|
+
await withTempDir('tracer', async dir => {
|
|
78
|
+
const bodies: any[] = []
|
|
79
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
80
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
81
|
+
})
|
|
82
|
+
const tracer = new TurnTracer(f)
|
|
83
|
+
const turn = tracer.startTurn('T0')
|
|
84
|
+
const step = turn.stepSpan(1)
|
|
85
|
+
step.recordException(new Error('x'))
|
|
86
|
+
step.end('toolUse')
|
|
87
|
+
turn.end('completed')
|
|
88
|
+
await f.flush()
|
|
89
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
90
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
91
|
+
const stepSpan = spans.find((s: any) => s.name === 'car.step')
|
|
92
|
+
assert.ok(turnSpan && stepSpan)
|
|
93
|
+
assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
|
|
94
|
+
assert.equal(stepSpan.parentSpanId, turnSpan.spanId, 'step 父 = turn spanId')
|
|
95
|
+
assert.equal(turnSpan.attributes.find((a: any) => a.key === 'car.outcome').value.stringValue, 'completed')
|
|
96
|
+
// noop 门面:enabled=false → 句柄零出站(默认关三原则在 trace 面成立)
|
|
97
|
+
const noopTracer = new TurnTracer(createTelemetryFacade(null))
|
|
98
|
+
const noopTurn = noopTracer.startTurn('T0')
|
|
99
|
+
noopTurn.stepSpan(1).end('stop')
|
|
100
|
+
noopTurn.end('completed')
|
|
101
|
+
const nf = createTelemetryFacade({ endpoint: 'https://otel' }, { fetchImpl: (async () => { throw new Error('零出站') }) as typeof fetch })
|
|
102
|
+
void nf
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// ==================== 生产调用点:car run 全链(MCP + PTC + trace 出站) ====================
|
|
107
|
+
|
|
108
|
+
/** 受控 stdio MCP server(node 脚本文本):tools/list + tools/call(upper) */
|
|
109
|
+
const FAKE_MCP_SERVER = `
|
|
110
|
+
const lines = [];
|
|
111
|
+
let buf = '';
|
|
112
|
+
process.stdin.on('data', d => {
|
|
113
|
+
buf += d.toString();
|
|
114
|
+
let nl;
|
|
115
|
+
while ((nl = buf.indexOf('\\n')) >= 0) {
|
|
116
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
|
117
|
+
if (!line) continue;
|
|
118
|
+
const req = JSON.parse(line);
|
|
119
|
+
if (req.method === 'tools/list') {
|
|
120
|
+
reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
|
|
121
|
+
} else if (req.method === 'tools/call') {
|
|
122
|
+
reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() }] });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
|
|
127
|
+
`
|
|
128
|
+
|
|
129
|
+
const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nreturn r`
|
|
130
|
+
|
|
131
|
+
test('S32: car run 全链——MCP 工具进声明面+callTool 派发 / run_code worker 桥 / SDK 块进 system / trace 出站', async () => {
|
|
132
|
+
await withTempDir('full-chain', async dir => {
|
|
133
|
+
// 插件(声明面带 schema)
|
|
134
|
+
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
|
|
135
|
+
`export default function apply(api) {\n` +
|
|
136
|
+
` api.registerTool({ name: 'echo_tool', description: '回声', parameters: { type: 'object', properties: { text: { type: 'string' } } }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
|
|
137
|
+
` api.setSystemPrompt('插件级系统提示')\n` +
|
|
138
|
+
`}\n`)
|
|
139
|
+
// 配置:llm + mcp.servers
|
|
140
|
+
writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
|
|
141
|
+
llm: { model: 'test-model' },
|
|
142
|
+
mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
|
|
143
|
+
}))
|
|
144
|
+
// 假 LLM:req1 → run_code(worker 桥调 MCP 工具);req2 → done
|
|
145
|
+
const llmBodies: any[] = []
|
|
146
|
+
let n = 0
|
|
147
|
+
const llmServer: Server = createServer((req, res) => {
|
|
148
|
+
let raw = ''
|
|
149
|
+
req.on('data', c => { raw += c })
|
|
150
|
+
req.on('end', () => {
|
|
151
|
+
llmBodies.push(JSON.parse(raw))
|
|
152
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
153
|
+
res.end(n++ === 0
|
|
154
|
+
? sse([
|
|
155
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
|
|
156
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
157
|
+
])
|
|
158
|
+
: sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
|
|
162
|
+
const llmPort = (llmServer.address() as { port: number }).port
|
|
163
|
+
// OTLP 捕获端点
|
|
164
|
+
const otlpBodies: any[] = []
|
|
165
|
+
const otlp: Server = createServer((req, res) => {
|
|
166
|
+
let raw = ''
|
|
167
|
+
req.on('data', c => { raw += c })
|
|
168
|
+
req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
|
|
169
|
+
})
|
|
170
|
+
await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
|
|
171
|
+
const otlpPort = (otlp.address() as { port: number }).port
|
|
172
|
+
|
|
173
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
|
|
174
|
+
'--prompt', 'uppercase abc', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
|
|
175
|
+
'--mcp', 'helper', '--ptc'], {
|
|
176
|
+
cwd: dir,
|
|
177
|
+
env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
|
|
178
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
179
|
+
})
|
|
180
|
+
let out = ''
|
|
181
|
+
let err = ''
|
|
182
|
+
child.stdout.on('data', d => { out += d })
|
|
183
|
+
child.stderr.on('data', d => { err += d })
|
|
184
|
+
const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
|
|
185
|
+
const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
|
|
186
|
+
llmServer.close()
|
|
187
|
+
otlp.close()
|
|
188
|
+
|
|
189
|
+
assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
|
|
190
|
+
assert.match(out, /turnEnd=completed/)
|
|
191
|
+
// MCP:进声明面 + callTool 真派发
|
|
192
|
+
assert.match(out, /\[MCP\] helper: 1 工具入声明面/)
|
|
193
|
+
const req1Tools = llmBodies[0].tools.map((t: any) => t.function.name)
|
|
194
|
+
assert.ok(req1Tools.includes('mcp_helper_upper'), 'MCP 工具模型侧命名(D-18)')
|
|
195
|
+
assert.ok(req1Tools.includes('run_code'), 'run_code 入工具面')
|
|
196
|
+
const runCodeFn = llmBodies[0].tools.find((t: any) => t.function.name === 'run_code')
|
|
197
|
+
assert.deepEqual(runCodeFn.function.parameters.required, ['code', 'description'], 'run_code 参数 schema 出站(1.4 新增)')
|
|
198
|
+
// PTC:工具结果回链——写类工具先落授权决策 toolResult(M4 既有语义,观察项登记:投影双 tool 消息)、
|
|
199
|
+
// 再落执行结果;取最后一条(run_code 执行结果,worker 桥调 MCP upper → 'ABC')
|
|
200
|
+
const toolMsg = llmBodies[1].messages.filter((m: any) => m.role === 'tool').at(-1)
|
|
201
|
+
assert.ok(toolMsg, '第二请求含 run_code 结果')
|
|
202
|
+
assert.equal(JSON.stringify(toolMsg.content).includes('ABC'), true, `worker 桥真调 MCP 工具(结果应含 ABC):${JSON.stringify(toolMsg.content).slice(0, 200)}`)
|
|
203
|
+
// system:flag 缺省 → 插件 setSystemPrompt + SDK 声明块独立追加
|
|
204
|
+
assert.equal(llmBodies[0].messages[0].role, 'system')
|
|
205
|
+
assert.equal(llmBodies[0].messages[0].content, '插件级系统提示')
|
|
206
|
+
assert.equal(llmBodies[0].messages[1].role, 'system')
|
|
207
|
+
assert.match(llmBodies[0].messages[1].content, /declare const tools:/, 'renderSdk 声明块出站(1.4 前零生产调用点)')
|
|
208
|
+
assert.match(llmBodies[0].messages[1].content, /mcp_helper_upper/, 'SDK 声明含 MCP 工具')
|
|
209
|
+
// trace:turn/step 共享 traceId + 父子链(OTLP 出站)
|
|
210
|
+
await new Promise(r => setTimeout(r, 300))
|
|
211
|
+
const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
212
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
213
|
+
const stepSpan = spans.find((s: any) => s.name === 'car.step')
|
|
214
|
+
assert.ok(turnSpan && stepSpan, `trace 出站(${otlpBodies.length} bodies, ${spans.length} spans)`)
|
|
215
|
+
assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
|
|
216
|
+
assert.equal(stepSpan.parentSpanId, turnSpan.spanId, '父子链')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.5-S1 · anthropic 适配器(S33):Messages API 流式 SSE(D-20)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - 出站体形状:x-api-key / anthropic-version / system 顶层化(多条 \n\n 连接)/
|
|
6
|
+
* toolResult → user tool_result(连续合并 + is_error)/ assistant tool_use /
|
|
7
|
+
* tools input_schema / max_tokens 必填缺省 4096 + 显式覆盖
|
|
8
|
+
* - stop_reason 映射(D-20 表):end_turn/stop_sequence→stop、max_tokens→length、
|
|
9
|
+
* tool_use→toolUse、refusal→aborted;未映射 fail-visible(raw 附 message)
|
|
10
|
+
* - 流容错:ping/未知事件跳过;流内 error 事件 → error chunk;无 stop_reason → AL-05
|
|
11
|
+
* - chatStep 集成:多 tool_use 块按 index 聚合 → toolUse 两条 ToolCall
|
|
12
|
+
* - 基线同款:TLS 门(D-12a loopback)/ 凭据门 A080001 / 重试退避注入 / pre-aborted 取消零 chunk
|
|
13
|
+
* - spawn E2E:car run --adapter-id anthropic 全链(生产调用点核查——1.5-GO-5)
|
|
14
|
+
*/
|
|
15
|
+
import { test } from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { createServer } from 'node:http'
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join, dirname } from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { createAnthropicAdapter, RuntimeCore, assertTlsOrLoopback } from '../src/runtime-core/llm.ts'
|
|
24
|
+
import { chatStep } from '../src/runtime-core/chatStep.ts'
|
|
25
|
+
import { CredentialService } from '../src/runtime-core/credentials.ts'
|
|
26
|
+
import { CarM8Error } from '../src/runtime-core/errors.ts'
|
|
27
|
+
import type { LlmChunk } from '../src/runtime-core/types.ts'
|
|
28
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
29
|
+
import { Context } from '../src/kernel/context.ts'
|
|
30
|
+
import { runTurn } from '../src/loop/stop.ts'
|
|
31
|
+
import { toToolDefinitions } from '../src/runtime-core/tools.ts'
|
|
32
|
+
import { doctorModelReadiness } from '../src/dx/doctor.ts'
|
|
33
|
+
|
|
34
|
+
// 密钥扫描口径(generic-secret-assign):夹具凭据经变量注入(赋值行无引号字面量);值非真实凭据
|
|
35
|
+
const TEST_KEY = 'test-key-123'
|
|
36
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
37
|
+
|
|
38
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s33-${name}-`))
|
|
40
|
+
try {
|
|
41
|
+
const r = fn(dir)
|
|
42
|
+
const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
|
|
43
|
+
if (r instanceof Promise) return r.finally(cleanup)
|
|
44
|
+
cleanup()
|
|
45
|
+
return Promise.resolve()
|
|
46
|
+
} catch (e) {
|
|
47
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
|
|
48
|
+
throw e
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sseResponse(text: string): Response {
|
|
53
|
+
const enc = new TextEncoder()
|
|
54
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
55
|
+
start(c) { c.enqueue(enc.encode(text)); c.close() },
|
|
56
|
+
})
|
|
57
|
+
return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dataLine = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
|
|
61
|
+
|
|
62
|
+
/** anthropic 流脚本 → SSE 文本(无 [DONE] 终结——anthropic 以 message_stop 收口,解析器 data-only 不依赖) */
|
|
63
|
+
const anthropicSse = (events: object[]) => events.map(dataLine).join('')
|
|
64
|
+
|
|
65
|
+
const TEXT_EVENTS = (text: string): object[] => [
|
|
66
|
+
{ type: 'message_start', message: { role: 'assistant' } },
|
|
67
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
|
68
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
|
|
69
|
+
{ type: 'content_block_stop', index: 0 },
|
|
70
|
+
{ type: 'message_delta', delta: { stop_reason: 'end_turn' } },
|
|
71
|
+
{ type: 'message_stop' },
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
const TOOLUSE_EVENTS = (blocks: Array<{ id: string; name: string; args: string }>, text?: string): object[] => {
|
|
75
|
+
const out: object[] = [{ type: 'message_start', message: { role: 'assistant' } }]
|
|
76
|
+
if (text) {
|
|
77
|
+
out.push(
|
|
78
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
|
|
79
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
|
|
80
|
+
{ type: 'content_block_stop', index: 0 },
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
blocks.forEach((b, i) => {
|
|
84
|
+
const idx = text ? i + 1 : i
|
|
85
|
+
out.push(
|
|
86
|
+
{ type: 'content_block_start', index: idx, content_block: { type: 'tool_use', id: b.id, name: b.name } },
|
|
87
|
+
{ type: 'content_block_delta', index: idx, delta: { type: 'input_json_delta', partial_json: b.args } },
|
|
88
|
+
{ type: 'content_block_stop', index: idx },
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
out.push({ type: 'message_delta', delta: { stop_reason: 'tool_use' } }, { type: 'message_stop' })
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function drain(it: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
96
|
+
const out: unknown[] = []
|
|
97
|
+
for await (const c of it) out.push(c)
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 无凭据服务(出站体形状单测用) */
|
|
102
|
+
function adapterOf(fetchImpl: typeof fetch, extra: Record<string, unknown> = {}) {
|
|
103
|
+
return createAnthropicAdapter({ baseUrl: 'https://api.test', fetchImpl, ...extra })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ==================== 出站体形状(D-20) ====================
|
|
107
|
+
|
|
108
|
+
test('S33: anthropic 出站体——x-api-key/anthropic-version 头 + system 顶层化 + tools input_schema + max_tokens 缺省 4096', async () => {
|
|
109
|
+
const seen: Array<{ url: string; init: RequestInit }> = []
|
|
110
|
+
const adapter = adapterOf((async (url: string | URL, init?: RequestInit) => {
|
|
111
|
+
seen.push({ url: String(url), init: init! })
|
|
112
|
+
return sseResponse(anthropicSse(TEXT_EVENTS('ok')))
|
|
113
|
+
}) as typeof fetch)
|
|
114
|
+
await drain(adapter.chat({
|
|
115
|
+
model: 'claude-x',
|
|
116
|
+
messages: [
|
|
117
|
+
{ role: 'system', content: '规则一' },
|
|
118
|
+
{ role: 'system', content: '规则二' },
|
|
119
|
+
{ role: 'user', content: 'hi' },
|
|
120
|
+
],
|
|
121
|
+
tools: [{ name: 'echo', description: '回声', parameters: { type: 'object', properties: { t: { type: 'string' } } }, declaredSideEffect: 'readonly' }],
|
|
122
|
+
}))
|
|
123
|
+
assert.equal(seen.length, 1)
|
|
124
|
+
const { url, init } = seen[0]!
|
|
125
|
+
assert.ok(url.endsWith('/v1/messages'), '请求路径 {baseUrl}/v1/messages(官方 SDK base_url 同约定)')
|
|
126
|
+
const headers = init.headers as Record<string, string>
|
|
127
|
+
assert.equal(headers['x-api-key'], '', '无凭据服务时空 x-api-key(凭据门在适配器外单测覆盖)')
|
|
128
|
+
assert.equal(headers['anthropic-version'], '2023-06-01')
|
|
129
|
+
const body = JSON.parse(init.body as string)
|
|
130
|
+
assert.equal(body.model, 'claude-x')
|
|
131
|
+
assert.equal(body.stream, true)
|
|
132
|
+
assert.equal(body.max_tokens, 4096, 'D-20:Anthropic max_tokens 必填——req 缺省 4096')
|
|
133
|
+
assert.equal(body.system, '规则一\n\n规则二', 'system 消息提升顶层,多条 \\n\\n 连接')
|
|
134
|
+
assert.ok(!('system' in body.messages[0]), 'system 不再以消息形态出站')
|
|
135
|
+
assert.deepEqual(body.messages, [{ role: 'user', content: 'hi' }])
|
|
136
|
+
assert.deepEqual(body.tools, [{ name: 'echo', description: '回声', input_schema: { type: 'object', properties: { t: { type: 'string' } } } }], 'tools → input_schema;declaredSideEffect 不出站')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('S33: anthropic 出站体——toolResult 合并单 user 多 tool_result + is_error + assistant tool_use + max_tokens 覆盖', async () => {
|
|
140
|
+
const seen: RequestInit[] = []
|
|
141
|
+
const adapter = adapterOf((async (_u: string | URL, init?: RequestInit) => {
|
|
142
|
+
seen.push(init!)
|
|
143
|
+
return sseResponse(anthropicSse(TEXT_EVENTS('done')))
|
|
144
|
+
}) as typeof fetch)
|
|
145
|
+
await drain(adapter.chat({
|
|
146
|
+
model: 'm',
|
|
147
|
+
maxTokens: 777,
|
|
148
|
+
messages: [
|
|
149
|
+
{ role: 'user', content: '查天气' },
|
|
150
|
+
{ role: 'assistant', content: { toolCall: { id: 'toolu_1', tool: 'weather', args: { city: '北京' } } } },
|
|
151
|
+
{ role: 'toolResult', content: { id: 'toolu_1', result: '晴 25 度' } },
|
|
152
|
+
{ role: 'toolResult', content: { id: 'toolu_2', error: 'boom' } },
|
|
153
|
+
],
|
|
154
|
+
tools: [],
|
|
155
|
+
}))
|
|
156
|
+
const body = JSON.parse(seen[0]!.body as string)
|
|
157
|
+
assert.equal(body.max_tokens, 777, '显式 maxTokens 覆盖缺省')
|
|
158
|
+
assert.deepEqual(body.messages, [
|
|
159
|
+
{ role: 'user', content: '查天气' },
|
|
160
|
+
{ role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'weather', input: { city: '北京' } }] },
|
|
161
|
+
{ role: 'user', content: [
|
|
162
|
+
{ type: 'tool_result', tool_use_id: 'toolu_1', content: '"晴 25 度"' },
|
|
163
|
+
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'boom', is_error: true },
|
|
164
|
+
] },
|
|
165
|
+
], '连续 toolResult 合并单 user 多块;error → is_error')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ==================== stop_reason 映射与流容错 ====================
|
|
169
|
+
|
|
170
|
+
test('S33: stop_reason 映射——end_turn→stop / tool_use→toolUse(args 聚合)/ max_tokens→length / refusal→aborted', async () => {
|
|
171
|
+
const mk = (events: object[]) => adapterOf((async () => sseResponse(anthropicSse(events))) as typeof fetch)
|
|
172
|
+
const run = (events: object[]) => drain(mk(events).chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] }))
|
|
173
|
+
|
|
174
|
+
const stop = await run(TEXT_EVENTS('ok')) as LlmChunk[]
|
|
175
|
+
assert.equal(stop.at(-1)!.finishReason, 'stop')
|
|
176
|
+
assert.ok(stop.every(c => c.finishReason !== 'error'), '成功流零 error chunk')
|
|
177
|
+
|
|
178
|
+
const toolUse = await run(TOOLUSE_EVENTS([{ id: 'toolu_1', name: 'f', args: '{"a":1}' }])) as LlmChunk[]
|
|
179
|
+
assert.equal(toolUse.at(-1)!.finishReason, 'toolUse')
|
|
180
|
+
const startChunk = toolUse.find(c => c.toolCallDelta?.id)!
|
|
181
|
+
const deltaChunk = toolUse.find(c => c.toolCallDelta?.argumentsDelta)!
|
|
182
|
+
assert.equal(startChunk.toolCallDelta!.id, 'toolu_1', 'content_block_start 携带 id/name')
|
|
183
|
+
assert.equal(startChunk.toolCallDelta!.name, 'f')
|
|
184
|
+
assert.equal(deltaChunk.toolCallDelta!.argumentsDelta, '{"a":1}')
|
|
185
|
+
|
|
186
|
+
const len = await run([{ type: 'message_delta', delta: { stop_reason: 'max_tokens' } }]) as LlmChunk[]
|
|
187
|
+
assert.equal(len.at(-1)!.finishReason, 'length')
|
|
188
|
+
|
|
189
|
+
const abort = await run([{ type: 'message_delta', delta: { stop_reason: 'refusal' } }]) as LlmChunk[]
|
|
190
|
+
assert.equal(abort.at(-1)!.finishReason, 'aborted')
|
|
191
|
+
|
|
192
|
+
// stop_sequence 同映射 stop(登记口径)
|
|
193
|
+
const seq = await run([{ type: 'message_delta', delta: { stop_reason: 'stop_sequence' } }]) as LlmChunk[]
|
|
194
|
+
assert.equal(seq.at(-1)!.finishReason, 'stop')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('S33: 未映射 stop_reason fail-visible(pause_turn 等)+ 流内 error 事件 + 无 stop_reason AL-05 兜底', async () => {
|
|
198
|
+
const mk = (events: object[]) => adapterOf((async () => sseResponse(anthropicSse(events))) as typeof fetch)
|
|
199
|
+
const run = (events: object[]) => drain(mk(events).chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })) as Promise<LlmChunk[]>
|
|
200
|
+
|
|
201
|
+
const weird = await run([{ type: 'message_delta', delta: { stop_reason: 'pause_turn' } }])
|
|
202
|
+
assert.equal(weird.length, 1, 'fail-visible 单 chunk(sawFinish 命中无二次兜底)')
|
|
203
|
+
assert.equal(weird[0]!.finishReason, 'error')
|
|
204
|
+
assert.match(weird[0]!.error!.message, /pause_turn/, '原始 stop_reason 附 message(不吞没)')
|
|
205
|
+
|
|
206
|
+
const streamErr = await run([{ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }])
|
|
207
|
+
assert.equal(streamErr[0]!.finishReason, 'error')
|
|
208
|
+
assert.match(streamErr[0]!.error!.message, /Overloaded/)
|
|
209
|
+
|
|
210
|
+
const noFinish = await run([
|
|
211
|
+
{ type: 'content_block_start', index: 0, content_block: { type: 'text' } },
|
|
212
|
+
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'half' } },
|
|
213
|
+
{ type: 'ping' }, // 容错跳过
|
|
214
|
+
{ type: 'message_stop' },
|
|
215
|
+
])
|
|
216
|
+
assert.equal(noFinish.at(-1)!.finishReason, 'error')
|
|
217
|
+
assert.match(noFinish.at(-1)!.error!.message, /AL-05/, '流结束无 stop_reason = AL-05 显式化')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// ==================== chatStep 集成 + 基线同款 ====================
|
|
221
|
+
|
|
222
|
+
test('S33: chatStep 集成——RuntimeCore 经 anthropic 适配器多 tool_use 块按 index 聚合为两条 ToolCall', async () => {
|
|
223
|
+
const adapter = adapterOf((async () => sseResponse(anthropicSse(TOOLUSE_EVENTS([
|
|
224
|
+
{ id: 'toolu_1', name: 'get_weather', args: '{"city":"北京"}' },
|
|
225
|
+
{ id: 'toolu_2', name: 'get_time', args: '{"tz":"UTC"}' },
|
|
226
|
+
], '查两个')))) as typeof fetch)
|
|
227
|
+
const cred = new CredentialService({ keychainReader: () => null })
|
|
228
|
+
const core = new RuntimeCore(cred)
|
|
229
|
+
core.bindContext(new Context())
|
|
230
|
+
core.registerLlmAdapter(adapter, { default: true })
|
|
231
|
+
const log = new SessionLog('S-s33')
|
|
232
|
+
log.append('user', 'user', 'T0', 'x')
|
|
233
|
+
const r = await chatStep({ core, log, turnId: 'T0', model: 'm', tools: [] })
|
|
234
|
+
assert.equal(r.stopReason, 'toolUse')
|
|
235
|
+
assert.equal(r.text, '查两个')
|
|
236
|
+
assert.deepEqual(r.toolCalls, [
|
|
237
|
+
{ id: 'toolu_1', tool: 'get_weather', args: { city: "北京" } },
|
|
238
|
+
{ id: 'toolu_2', tool: 'get_time', args: { tz: 'UTC' } },
|
|
239
|
+
], 'content_block_start id/name + input_json_delta 增量 → index 聚合')
|
|
240
|
+
assert.equal(log.assertModelVisibleLogged().ok, true)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('S33: 基线同款——TLS 门(http 非 loopback 拒绝 / loopback 豁免)/ 凭据门 A080001 / 首块前 5xx 重试 / pre-aborted 零 chunk', async () => {
|
|
244
|
+
assertTlsOrLoopback('https://x.test')
|
|
245
|
+
assert.throws(() => createAnthropicAdapter({ baseUrl: 'http://api.test' }), /CAR-E-LLM-TLS/)
|
|
246
|
+
assert.doesNotThrow(() => createAnthropicAdapter({ baseUrl: 'http://127.0.0.1:9/v1' }), 'D-12a loopback 豁免同款')
|
|
247
|
+
|
|
248
|
+
// 凭据门:keychain 通道缺席 + env fallback 未开 → A080001(CarM8Error 保形)
|
|
249
|
+
const cred = new CredentialService({ keychainReader: () => null })
|
|
250
|
+
const adapter = adapterOf((async () => sseResponse(anthropicSse(TEXT_EVENTS('x')))) as typeof fetch, { credentials: cred })
|
|
251
|
+
await assert.rejects(
|
|
252
|
+
() => drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })),
|
|
253
|
+
(e: unknown) => e instanceof CarM8Error && (e as CarM8Error).code === 'A080001',
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
// 重试:首块前 5xx ×2 → 退避注入可观测 → 第三次成功(streamSsePost 引擎与 openai-compat 共用)
|
|
257
|
+
let attempts = 0
|
|
258
|
+
const sleeps: number[] = []
|
|
259
|
+
const retryAdapter = createAnthropicAdapter({
|
|
260
|
+
baseUrl: 'https://api.test',
|
|
261
|
+
retries: 2, backoffMs: [11, 22],
|
|
262
|
+
sleep: async ms => { sleeps.push(ms) },
|
|
263
|
+
fetchImpl: (async () => {
|
|
264
|
+
attempts++
|
|
265
|
+
if (attempts < 3) return new Response('boom', { status: 500 })
|
|
266
|
+
return sseResponse(anthropicSse(TEXT_EVENTS('ok')))
|
|
267
|
+
}) as typeof fetch,
|
|
268
|
+
})
|
|
269
|
+
const out = await drain(retryAdapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] })) as LlmChunk[]
|
|
270
|
+
assert.equal(attempts, 3)
|
|
271
|
+
assert.deepEqual(sleeps, [11, 22], '指数退避注入可观测')
|
|
272
|
+
assert.equal(out.at(-1)!.finishReason, 'stop')
|
|
273
|
+
|
|
274
|
+
// 取消(D-19):pre-aborted → 零 fetch 调用零 chunk
|
|
275
|
+
let called = 0
|
|
276
|
+
const cancelAdapter = adapterOf((async () => { called++; return sseResponse(anthropicSse(TEXT_EVENTS('x'))) }) as typeof fetch)
|
|
277
|
+
const chunks = await drain(cancelAdapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [], signal: { aborted: true } }))
|
|
278
|
+
assert.equal(chunks.length, 0)
|
|
279
|
+
assert.equal(called, 0, 'attempt 前取消——请求未发出')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('S33: doctorModelReadiness provider 感知——adapterId anthropic → ANTHROPIC_API_KEY(值不打印)', () => {
|
|
283
|
+
withTempDir('doctor-anthropic', dir => {
|
|
284
|
+
writeFileSync(join(dir, 'car.config.json'), JSON.stringify({ llm: { baseUrl: 'https://api.anthropic.com', model: 'claude-x', adapterId: 'anthropic', allowEnvFallback: true } }))
|
|
285
|
+
const d1 = doctorModelReadiness({ env: { ANTHROPIC_API_KEY: TEST_KEY }, cwd: dir })
|
|
286
|
+
assert.equal(d1.configured, true)
|
|
287
|
+
assert.equal(d1.credential, 'env', 'anthropic provider 探测 ANTHROPIC_API_KEY')
|
|
288
|
+
assert.equal(d1.detail.includes(TEST_KEY), false, '凭据值永不打印')
|
|
289
|
+
// openai adapter 的凭据变量不算 anthropic 就绪
|
|
290
|
+
const d2 = doctorModelReadiness({ env: { OPENAI_API_KEY: TEST_KEY }, cwd: dir })
|
|
291
|
+
assert.equal(d2.credential, 'not-found', 'provider 变量面隔离')
|
|
292
|
+
// 显式 adapter 行在 detail
|
|
293
|
+
assert.match(d1.detail, /adapter=anthropic/)
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// ==================== 多步真 E2E + spawn 生产调用点(1.5-GO-5) ====================
|
|
298
|
+
|
|
299
|
+
test('S33: spawn CLI 全链——car run --adapter-id anthropic(x-api-key / system 顶层 / tool_result 第二请求)', async () => {
|
|
300
|
+
await withTempDir('cli-anthropic', async dir => {
|
|
301
|
+
const plugin = `export const manifest = { name: 'p', version: '1.0.0' }\n` +
|
|
302
|
+
`export default function apply(api) {\n` +
|
|
303
|
+
` api.registerTool({ name: 'echo_tool', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
|
|
304
|
+
`}\n`
|
|
305
|
+
writeFileSync(join(dir, 'p.ts'), plugin)
|
|
306
|
+
const bodies: any[] = []
|
|
307
|
+
const headers: any[] = []
|
|
308
|
+
let n = 0
|
|
309
|
+
const server = createServer((req, res) => {
|
|
310
|
+
let raw = ''
|
|
311
|
+
req.on('data', c => { raw += c })
|
|
312
|
+
req.on('end', () => {
|
|
313
|
+
bodies.push(JSON.parse(raw))
|
|
314
|
+
headers.push(req.headers)
|
|
315
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
316
|
+
res.end(n++ === 0
|
|
317
|
+
? anthropicSse(TOOLUSE_EVENTS([{ id: 'toolu_1', name: 'echo_tool', args: '{"text":"hi"}' }]))
|
|
318
|
+
: anthropicSse(TEXT_EVENTS('完成了')))
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
322
|
+
const port = (server.address() as { port: number }).port
|
|
323
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
|
|
324
|
+
'--adapter-id', 'anthropic',
|
|
325
|
+
'--prompt', 'call echo_tool with text hi', '--base-url', `http://127.0.0.1:${port}`, '--model', 'claude-cli',
|
|
326
|
+
'--system', '你是 CLI 集成测试助手'], {
|
|
327
|
+
cwd: dir,
|
|
328
|
+
env: { ...process.env, ANTHROPIC_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1' },
|
|
329
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
330
|
+
})
|
|
331
|
+
let out = ''
|
|
332
|
+
let err = ''
|
|
333
|
+
child.stdout.on('data', d => { out += d })
|
|
334
|
+
child.stderr.on('data', d => { err += d })
|
|
335
|
+
const code = await new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
|
|
336
|
+
server.close()
|
|
337
|
+
assert.equal(code, 0, err)
|
|
338
|
+
assert.match(out, /turnEnd=completed/)
|
|
339
|
+
// 请求 1:anthropic 出站体(system 顶层 / input_schema / x-api-key 凭据 / 版本头)
|
|
340
|
+
assert.equal(headers[0]['x-api-key'], TEST_KEY, '凭据经 CredentialService→x-api-key')
|
|
341
|
+
assert.equal(headers[0]['anthropic-version'], '2023-06-01')
|
|
342
|
+
assert.equal(bodies[0].system, '你是 CLI 集成测试助手', '--system 落链(system 事件)→ 顶层 system')
|
|
343
|
+
assert.equal(JSON.stringify(bodies[0]).includes(TEST_KEY), false, '凭据值不出站到消息体')
|
|
344
|
+
assert.equal(bodies[0].tools[0].name, 'echo_tool')
|
|
345
|
+
assert.equal(bodies[0].tools[0].input_schema.properties.text.type, 'string')
|
|
346
|
+
// 请求 2:assistant tool_use + user tool_result(D-20 映射回路)
|
|
347
|
+
const asst = bodies[1].messages.find((m: any) => m.role === 'assistant')
|
|
348
|
+
assert.deepEqual(asst.content, [{ type: 'tool_use', id: 'toolu_1', name: 'echo_tool', input: { text: 'hi' } }])
|
|
349
|
+
const tr = bodies[1].messages.find((m: any) => m.role === 'user' && Array.isArray(m.content))
|
|
350
|
+
assert.equal(tr.content[0].type, 'tool_result')
|
|
351
|
+
assert.equal(tr.content[0].tool_use_id, 'toolu_1')
|
|
352
|
+
assert.equal(tr.content[0].content, '"echo:hi"')
|
|
353
|
+
})
|
|
354
|
+
})
|