@lqc123qwe/car-runtime 1.4.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 +16 -1
- package/package.json +4 -1
- package/src/cli.ts +32 -15
- package/src/dx/doctor.ts +9 -7
- package/src/load/config.ts +205 -137
- package/src/mcp/httpClient.ts +176 -0
- package/src/runtime-core/chatStep.ts +9 -3
- package/src/runtime-core/credentials.ts +6 -1
- package/src/runtime-core/llm.ts +289 -75
- 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
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,15 +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
|
-
// 本地 E2E;非回环地址维持 TLS 强制(§3.2.M8.4)。豁免集显式列举,不扩大到局域网。
|
|
204
|
-
let loopback = false
|
|
205
|
-
try { const host = new URL(baseUrl).hostname.replace(/^\[|\]$/g, ''); loopback = host === '127.0.0.1' || host === 'localhost' || host === '::1' } catch { /* 解析失败 = 非法 URL,按非回环拒绝 */ }
|
|
206
|
-
if (!loopback) {
|
|
207
|
-
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 代理或自建适配器`)
|
|
208
|
-
}
|
|
209
|
-
}
|
|
300
|
+
// 1.4-S4(D-12a,1.2 规划期裁决):TLS 门 1.5-S1 抽取共用(口径与报错文案不变)
|
|
301
|
+
assertTlsOrLoopback(baseUrl)
|
|
210
302
|
const url = `${baseUrl}/chat/completions`
|
|
211
303
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
212
304
|
const sleep = opts.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
|
|
@@ -218,7 +310,7 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
|
|
|
218
310
|
const toOpenAI = (req: LlmRequest, apiKey: string) => ({
|
|
219
311
|
// 1.4-BUG-1:method: 'POST' 缺失——M8 最小实现潜伏缺陷(注入式 fetch 的单测忽略 method,
|
|
220
312
|
// 真 undici 对 GET+body 直接拒绝);car run 真路径接线时暴露(生产调用点核查产出)
|
|
221
|
-
method: 'POST',
|
|
313
|
+
method: 'POST' as const,
|
|
222
314
|
headers: {
|
|
223
315
|
'content-type': 'application/json',
|
|
224
316
|
authorization: `Bearer ${apiKey}`,
|
|
@@ -249,72 +341,194 @@ export function createOpenAICompatAdapter(opts: OpenAICompatOptions): LlmAdapter
|
|
|
249
341
|
|
|
250
342
|
return {
|
|
251
343
|
id,
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
for (let attempt = 0; ; attempt++) {
|
|
262
|
-
// 1.4-S4(D-19):外部取消 ≠ 失败——attempt 前检查,静默收口(无 chunk、无 retry)
|
|
263
|
-
if (req.signal?.aborted) return
|
|
264
|
-
const controller = new AbortController()
|
|
265
|
-
const started = Date.now()
|
|
266
|
-
let firstByteSeen = false
|
|
267
|
-
const firstByteTimer = setTimeout(() => controller.abort(), firstByteMs)
|
|
268
|
-
const overallTimer = setTimeout(() => controller.abort(), timeoutMs)
|
|
269
|
-
try {
|
|
270
|
-
const res = await fetchImpl(url, { ...payload, signal: controller.signal })
|
|
271
|
-
if (res.status === 429 || res.status >= 500) throw new Retryable(`HTTP ${res.status}`)
|
|
272
|
-
if (!res.ok) {
|
|
273
|
-
const text = await res.text().catch(() => '')
|
|
274
|
-
// 4xx 业务错不重试(§3.5.4);错误信息不含凭据(Bearer 值不进错误路径)
|
|
275
|
-
yield { finishReason: 'error', error: { code: 'B080001', message: `provider HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ''}` } }
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
if (!res.body) throw new Retryable('响应无 body 流')
|
|
279
|
-
let sawFinish = false
|
|
280
|
-
for await (const chunk of parseSseStream(res.body)) {
|
|
281
|
-
// 1.4-S4(D-19):chunk 间隙检查取消——abort 连接并静默收口(不产 error chunk,
|
|
282
|
-
// 不违反 finishReason 不可变;上层 chatStep 以自身 signal 检查抛 TurnAborted)
|
|
283
|
-
if (req.signal?.aborted) { controller.abort(); return }
|
|
284
|
-
if (!firstByteSeen) { firstByteSeen = true; clearTimeout(firstByteTimer) }
|
|
285
|
-
if (chunk.finishReason !== undefined) sawFinish = true
|
|
286
|
-
yield chunk
|
|
287
|
-
}
|
|
288
|
-
// 流自然结束但未给 finishReason = 异常终止(AL-05 口径:缺失显式化,禁吞没);
|
|
289
|
-
// 已携带 finishReason 的流原样收口——兜底不得追加(M8-BUG-1:无条件 error chunk
|
|
290
|
-
// 会污染成功流并触发 finishReason 守卫冲突;成功流零 error chunk 由 s25 断言钉死)
|
|
291
|
-
if (!sawFinish) {
|
|
292
|
-
yield { finishReason: 'error', error: { code: 'B080001', message: '流结束未携带 finish_reason(AL-05)' } }
|
|
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, {})
|
|
293
353
|
}
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 } }
|
|
316
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)}` } }
|
|
317
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)
|
|
318
532
|
},
|
|
319
533
|
}
|
|
320
534
|
}
|