@huyooo/ai-chat-core 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Anthropic Protocol(通过 Vercel AI Gateway)
3
+ *
4
+ * 特点:
5
+ * - 使用 Vercel AI SDK 的 createGateway
6
+ * - 支持 extended thinking
7
+ * - 支持工具调用
8
+ * - 自动处理流式输出
9
+ *
10
+ * 参考: https://vercel.com/docs/ai-gateway
11
+ */
12
+
13
+ import { streamText, createGateway, tool } from 'ai';
14
+ import { z } from 'zod';
15
+ import type {
16
+ Protocol,
17
+ ProtocolConfig,
18
+ ProtocolMessage,
19
+ ProtocolToolDefinition,
20
+ ProtocolRequestOptions,
21
+ RawEvent,
22
+ } from './types';
23
+ import { DebugLogger } from '../../utils';
24
+
25
+ const logger = DebugLogger.module('AnthropicProtocol');
26
+
27
+ /** Gateway 类型 */
28
+ type GatewayFunction = ReturnType<typeof createGateway>;
29
+
30
+ /** AI SDK 消息类型 */
31
+ type CoreMessage = Parameters<typeof streamText>[0]['messages'] extends infer M
32
+ ? M extends readonly (infer T)[] ? T : never
33
+ : never;
34
+
35
+ /**
36
+ * Anthropic Protocol 实现(通过 Vercel AI Gateway)
37
+ */
38
+ export class AnthropicProtocol implements Protocol {
39
+ readonly name = 'anthropic';
40
+
41
+ private gateway: GatewayFunction;
42
+
43
+ constructor(config: ProtocolConfig) {
44
+ // 创建 Vercel AI Gateway 客户端
45
+ this.gateway = createGateway({
46
+ apiKey: config.apiKey,
47
+ });
48
+ }
49
+
50
+ async *stream(
51
+ messages: ProtocolMessage[],
52
+ tools: ProtocolToolDefinition[],
53
+ options: ProtocolRequestOptions
54
+ ): AsyncGenerator<RawEvent> {
55
+ logger.debug('使用 Vercel AI Gateway 调用 Claude', {
56
+ model: options.model,
57
+ enableThinking: options.enableThinking,
58
+ toolsCount: tools.length,
59
+ });
60
+
61
+ try {
62
+ // 转换消息格式(简化版:只处理 user/assistant 文本消息)
63
+ const sdkMessages = this.convertMessages(messages);
64
+
65
+ // 构建 streamText 参数
66
+ const streamParams: Parameters<typeof streamText>[0] = {
67
+ model: this.gateway(options.model),
68
+ messages: sdkMessages,
69
+ maxOutputTokens: options.familyConfig.defaultMaxTokens,
70
+ abortSignal: options.signal,
71
+ };
72
+
73
+ // 添加工具(如果有)
74
+ if (tools.length > 0) {
75
+ streamParams.tools = this.convertTools(tools);
76
+ }
77
+
78
+ // 添加 thinking 配置
79
+ if (options.enableThinking) {
80
+ streamParams.providerOptions = {
81
+ anthropic: {
82
+ thinking: {
83
+ type: 'enabled',
84
+ budgetTokens: 10000,
85
+ },
86
+ },
87
+ };
88
+ }
89
+
90
+ logger.debug('发送请求到 Vercel AI Gateway', {
91
+ model: options.model,
92
+ messagesCount: sdkMessages.length,
93
+ toolsCount: tools.length,
94
+ enableThinking: options.enableThinking,
95
+ });
96
+
97
+ // 调用 streamText
98
+ const result = streamText(streamParams);
99
+
100
+ // 处理流式响应
101
+ yield* this.parseStream(result);
102
+
103
+ } catch (error: unknown) {
104
+ const friendly = extractGatewayErrorMessage(error);
105
+ logger.error('Vercel AI Gateway 错误', { error: friendly });
106
+ yield { type: 'error', error: friendly };
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 转换消息格式(ProtocolMessage → AI SDK CoreMessage)
112
+ * 简化版:主要处理文本消息,工具调用通过历史上下文传递
113
+ */
114
+ private convertMessages(messages: ProtocolMessage[]): CoreMessage[] {
115
+ const result: CoreMessage[] = [];
116
+
117
+ for (const msg of messages) {
118
+ switch (msg.role) {
119
+ case 'system':
120
+ result.push({ role: 'system', content: msg.content });
121
+ break;
122
+
123
+ case 'user': {
124
+ // 当只有图片没有文字时提供默认提示
125
+ const textContent = msg.content || (msg.images?.length ? '请分析这张图片' : '');
126
+ if (msg.images?.length) {
127
+ // 多模态消息
128
+ const content: Array<{ type: 'text'; text: string } | { type: 'image'; image: string }> = [
129
+ { type: 'text', text: textContent }
130
+ ];
131
+ for (const img of msg.images) {
132
+ content.push({
133
+ type: 'image',
134
+ image: img.startsWith('data:') ? img : `data:image/jpeg;base64,${img}`,
135
+ });
136
+ }
137
+ result.push({ role: 'user', content });
138
+ } else {
139
+ result.push({ role: 'user', content: textContent });
140
+ }
141
+ break;
142
+ }
143
+
144
+ case 'assistant':
145
+ // 简化处理:只保留文本内容,工具调用信息暂时忽略
146
+ // AI SDK 6.x 的工具调用格式较复杂,需要完整的 tool-call/tool-result 对
147
+ if (msg.content) {
148
+ result.push({ role: 'assistant', content: msg.content });
149
+ }
150
+ break;
151
+
152
+ case 'tool':
153
+ // 工具结果作为用户消息传递(简化处理)
154
+ result.push({
155
+ role: 'user',
156
+ content: `[工具 ${msg.toolName || 'unknown'} 返回结果]: ${msg.content}`
157
+ });
158
+ break;
159
+ }
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ /**
166
+ * 转换工具格式(ProtocolToolDefinition → AI SDK tools)
167
+ * 注意:Vercel Gateway 对工具格式有特殊要求,这里使用标准格式
168
+ */
169
+ private convertTools(tools: ProtocolToolDefinition[]): NonNullable<Parameters<typeof streamText>[0]['tools']> {
170
+ const result: NonNullable<Parameters<typeof streamText>[0]['tools']> = {};
171
+
172
+ for (const t of tools) {
173
+ // 将 JSON Schema 转换为 zod schema
174
+ const zodSchema = this.jsonSchemaToZod(t.parameters);
175
+ // 使用类型断言绕过复杂的泛型类型问题
176
+ (result as Record<string, unknown>)[t.name] = tool({
177
+ description: t.description,
178
+ inputSchema: zodSchema,
179
+ });
180
+ }
181
+
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * 将 JSON Schema 转换为 Zod Schema
187
+ */
188
+ private jsonSchemaToZod(schema: ProtocolToolDefinition['parameters']): z.ZodType {
189
+ const shape: Record<string, z.ZodTypeAny> = {};
190
+
191
+ for (const [key, prop] of Object.entries(schema.properties)) {
192
+ let zodType: z.ZodTypeAny;
193
+
194
+ switch (prop.type) {
195
+ case 'string':
196
+ zodType = prop.enum ? z.enum(prop.enum as [string, ...string[]]) : z.string();
197
+ break;
198
+ case 'number':
199
+ case 'integer':
200
+ zodType = z.number();
201
+ break;
202
+ case 'boolean':
203
+ zodType = z.boolean();
204
+ break;
205
+ case 'array':
206
+ zodType = z.array(z.string()); // 简化处理
207
+ break;
208
+ default:
209
+ zodType = z.unknown();
210
+ }
211
+
212
+ // 添加描述
213
+ if (prop.description) {
214
+ zodType = zodType.describe(prop.description);
215
+ }
216
+
217
+ // 处理可选性
218
+ if (!schema.required?.includes(key)) {
219
+ zodType = zodType.optional();
220
+ }
221
+
222
+ shape[key] = zodType;
223
+ }
224
+
225
+ return z.object(shape);
226
+ }
227
+
228
+ /**
229
+ * 解析 AI SDK 流式响应
230
+ */
231
+ private async *parseStream(
232
+ result: ReturnType<typeof streamText>
233
+ ): AsyncGenerator<RawEvent> {
234
+ const toolCalls = new Map<string, { id: string; name: string; arguments: string }>();
235
+ let hasThinking = false;
236
+ let thinkingDone = false;
237
+
238
+ for await (const part of result.fullStream) {
239
+ switch (part.type) {
240
+ case 'reasoning-delta':
241
+ // 处理思考内容(AI SDK 6.x 使用 text 属性)
242
+ if ('text' in part && part.text) {
243
+ hasThinking = true;
244
+ yield { type: 'thinking_delta', delta: part.text };
245
+ }
246
+ break;
247
+
248
+ case 'reasoning-end':
249
+ // 思考结束
250
+ if (hasThinking && !thinkingDone) {
251
+ thinkingDone = true;
252
+ yield { type: 'thinking_done' };
253
+ }
254
+ break;
255
+
256
+ case 'text-delta':
257
+ // 处理文本内容
258
+ // 如果有 thinking 但还没发送 thinking_done,先发送
259
+ if (hasThinking && !thinkingDone) {
260
+ thinkingDone = true;
261
+ yield { type: 'thinking_done' };
262
+ }
263
+ if ('text' in part && part.text) {
264
+ yield { type: 'text_delta', delta: part.text };
265
+ }
266
+ break;
267
+
268
+ case 'tool-call':
269
+ // 处理工具调用
270
+ if ('toolCallId' in part && 'toolName' in part) {
271
+ const toolCallId = part.toolCallId;
272
+ const toolName = part.toolName;
273
+ const input = 'input' in part ? part.input : null;
274
+ const args = input ? (typeof input === 'string' ? input : JSON.stringify(input)) : '{}';
275
+
276
+ // 发送开始事件
277
+ yield {
278
+ type: 'tool_call_start',
279
+ toolCall: { id: toolCallId, name: toolName },
280
+ };
281
+
282
+ // 保存工具调用信息
283
+ toolCalls.set(toolCallId, {
284
+ id: toolCallId,
285
+ name: toolName,
286
+ arguments: args,
287
+ });
288
+
289
+ // 发送完成事件
290
+ yield {
291
+ type: 'tool_call_done',
292
+ toolCall: {
293
+ id: toolCallId,
294
+ name: toolName,
295
+ arguments: args,
296
+ },
297
+ };
298
+ }
299
+ break;
300
+
301
+ case 'error': {
302
+ // AI SDK fullStream 产出的 error 事件(如 402 余额不足、500 等)
303
+ // error 字段结构:{ statusCode?, cause?: { responseBody?, data? } }
304
+ const sdkError = 'error' in part ? (part as any).error : undefined;
305
+ const friendly = sdkError
306
+ ? extractGatewayErrorMessage(sdkError)
307
+ : '模型请求失败';
308
+ logger.error('AI SDK stream error 事件', { error: friendly });
309
+ yield { type: 'error', error: friendly };
310
+ return;
311
+ }
312
+
313
+ case 'finish': {
314
+ // 处理完成
315
+ const finishReason = toolCalls.size > 0 ? 'tool_calls' :
316
+ ('finishReason' in part && part.finishReason === 'stop') ? 'stop' :
317
+ ('finishReason' in part && part.finishReason === 'length') ? 'length' : 'stop';
318
+
319
+ // 提取 Token 使用统计
320
+ // AI SDK 6.x: totalUsage 字段,LanguageModelUsage = { inputTokens, outputTokens }
321
+ const rawUsage = 'totalUsage' in part ? (part as any).totalUsage
322
+ : 'usage' in part ? (part as any).usage
323
+ : undefined;
324
+ const inputTokens = rawUsage?.promptTokens ?? rawUsage?.inputTokens ?? 0;
325
+ const outputTokens = rawUsage?.completionTokens ?? rawUsage?.outputTokens ?? 0;
326
+ const usage = rawUsage ? {
327
+ promptTokens: inputTokens,
328
+ completionTokens: outputTokens,
329
+ totalTokens: rawUsage.totalTokens ?? (inputTokens + outputTokens),
330
+ } : undefined;
331
+
332
+ yield { type: 'done', finishReason, usage };
333
+ return;
334
+ }
335
+ }
336
+ }
337
+
338
+ // 流结束但没有 finish 事件
339
+ yield { type: 'done', finishReason: 'stop' };
340
+ }
341
+ }
342
+
343
+ /**
344
+ * 从 Vercel AI Gateway / AI SDK 错误中提取用户友好的错误消息
345
+ *
346
+ * GatewayInternalServerError 结构:
347
+ * - error.message: "Insufficient funds..."
348
+ * - error.statusCode: 402
349
+ * - error.cause?.responseBody: '{"error":{"message":"...","type":"insufficient_funds"}}'
350
+ * - error.cause?.statusCode: 402
351
+ */
352
+ function extractGatewayErrorMessage(error: unknown): string {
353
+ if (!error || typeof error !== 'object') return '未知错误';
354
+
355
+ // 兼容 Error 实例和 AI SDK fullStream error 事件的普通对象
356
+ const err = error as {
357
+ message?: string;
358
+ statusCode?: number;
359
+ cause?: { statusCode?: number; responseBody?: string; data?: { error?: { message?: string; type?: string } } };
360
+ };
361
+
362
+ // 1. 尝试从 cause.data.error 提取结构化信息
363
+ const apiError = err.cause?.data?.error;
364
+ if (apiError?.message) {
365
+ const statusCode = err.cause?.statusCode || err.statusCode;
366
+ if (apiError.type === 'insufficient_funds' || statusCode === 402) {
367
+ return '账户余额不足,请充值后重试';
368
+ }
369
+ if (statusCode === 401) {
370
+ return 'API 认证失败,请检查 API Key 配置';
371
+ }
372
+ if (statusCode === 429) {
373
+ return '请求过于频繁,请稍后重试';
374
+ }
375
+ return `API 错误: ${apiError.message}`;
376
+ }
377
+
378
+ // 2. 尝试从 cause.responseBody 解析
379
+ if (err.cause?.responseBody) {
380
+ try {
381
+ const body = JSON.parse(err.cause.responseBody);
382
+ if (body?.error?.message) {
383
+ const statusCode = err.cause?.statusCode || err.statusCode;
384
+ if (body.error.type === 'insufficient_funds' || statusCode === 402) {
385
+ return '账户余额不足,请充值后重试';
386
+ }
387
+ return `API 错误: ${body.error.message}`;
388
+ }
389
+ } catch { /* ignore parse error */ }
390
+ }
391
+
392
+ // 3. 按 statusCode 兜底
393
+ const statusCode = err.statusCode || err.cause?.statusCode;
394
+ if (statusCode === 402) return '账户余额不足,请充值后重试';
395
+ if (statusCode === 401) return 'API 认证失败,请检查 API Key 配置';
396
+ if (statusCode === 429) return '请求过于频繁,请稍后重试';
397
+ if (statusCode === 503) return '服务暂时不可用,请稍后重试';
398
+
399
+ // 4. 直接用 error.message,但截断过长的内容
400
+ const msg = err.message || '未知错误';
401
+ return msg.length > 200 ? msg.slice(0, 200) + '...' : msg;
402
+ }
403
+
404
+ export function createAnthropicProtocol(config: ProtocolConfig): AnthropicProtocol {
405
+ return new AnthropicProtocol(config);
406
+ }