autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
280 lines (279 loc) • 11.2 kB
JavaScript
/**
* ClaudeProvider - Anthropic Claude AI 提供商
*
* v2: 支持原生 Function Calling(结构化工具调用)
* - 使用 Anthropic Messages API 的 tools + tool_choice 参数
* - 响应中的 tool_use content blocks → 结构化 functionCall
* - tool_result content blocks 用于回传工具执行结果
*/
import Logger from '#infra/logging/Logger.js';
import { AiProvider, } from '../AiProvider.js';
const CLAUDE_BASE = 'https://api.anthropic.com/v1';
const ANTHROPIC_VERSION = '2023-06-01';
export class ClaudeProvider extends AiProvider {
constructor(config = {}) {
super(config);
this.name = 'claude';
this.model = config.model || 'claude-sonnet-4-20250514';
this.apiKey = config.apiKey || process.env.ASD_CLAUDE_API_KEY || '';
this.maxRetries = 0; // Claude 不做重试
this.logger = Logger.getInstance();
}
/** 是否支持原生结构化函数调用 */
get supportsNativeToolCalling() {
return true;
}
async chat(prompt, context = {}) {
const { history = [], temperature = 0.7, maxTokens = 4096 } = context;
const messages = [];
for (const h of history) {
messages.push({ role: h.role, content: h.content });
}
messages.push({ role: 'user', content: prompt });
const body = {
model: this.model,
messages,
max_tokens: maxTokens,
temperature,
};
if (context.systemPrompt) {
body.system = context.systemPrompt;
}
const data = await this._post(`${CLAUDE_BASE}/messages`, body);
// 提取 token 用量
if (data?.usage) {
this._emitTokenUsage({
inputTokens: data.usage.input_tokens || 0,
outputTokens: data.usage.output_tokens || 0,
totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
});
}
const textBlock = (data?.content || []).find((c) => c.type === 'text');
return textBlock?.text || '';
}
/**
* 带工具声明的结构化对话 — Anthropic Messages API Tool Use
*
* 接受统一消息格式,内部转换为 Anthropic Messages 格式。
*
* Anthropic 特殊之处:
* - system prompt 是顶层 `system` 字段(非 message)
* - assistant 消息的 content 是 content blocks 数组
* - 工具结果通过 user 消息中的 tool_result blocks 传递
* - tool_choice: {type: 'auto'|'any'|'tool'}(无 'none',不传 tools 即可)
*
* @param prompt fallback prompt
* @param opts 统一参数
* @returns >|null}>}
*/
async chatWithTools(prompt, opts = {}) {
const { messages: rawMessages, toolSchemas: rawToolSchemas, toolChoice = 'auto', systemPrompt, temperature = 0.7, maxTokens = 4096, } = opts;
const unifiedMessages = rawMessages;
const toolSchemas = rawToolSchemas;
// 统一消息 → Anthropic Messages 格式
const srcMessages = unifiedMessages && unifiedMessages.length > 0
? unifiedMessages
: [{ role: 'user', content: prompt }];
const messages = this.#convertMessages(srcMessages);
const body = {
model: this.model,
messages,
max_tokens: maxTokens,
temperature,
};
// system prompt → 顶层字段
if (systemPrompt) {
body.system = systemPrompt;
}
// 工具声明 + tool_choice
// toolChoice='none' 时不传 tools(Anthropic 没有 'none' tool_choice)
if (toolChoice !== 'none' && toolSchemas && toolSchemas.length > 0) {
body.tools = toolSchemas.map((s) => ({
name: s.name,
description: s.description || '',
input_schema: s.parameters || { type: 'object', properties: {} },
}));
if (toolChoice === 'required') {
body.tool_choice = { type: 'any' };
}
else {
body.tool_choice = { type: 'auto' };
}
}
const data = await this._post(`${CLAUDE_BASE}/messages`, body, opts.abortSignal);
return this.#parseToolResponse(data);
}
// ─── 内部转换方法 ──────────────────────
/**
* 统一消息格式 → Anthropic Messages 格式
*
* - user → {role: 'user', content: 'text'}
* - assistant → {role: 'assistant', content: [{type:'text'}, {type:'tool_use'}...]}
* - tool → grouped into {role: 'user', content: [{type:'tool_result'}...]}
*
* Anthropic 要求消息交替 user/assistant。连续 tool results 合并为一个 user 消息。
* 连续同角色消息(如 L2/L3 压缩后的摘要)自动合并以避免 400 错误。
*/
#convertMessages(messages) {
const result = [];
/** 推入 result,如果上一个 entry 同角色则合并 content */
const pushOrMerge = (entry) => {
const last = result[result.length - 1];
if (last && last.role === entry.role) {
// Anthropic content 可以是 string 或 array
const lastContent = Array.isArray(last.content)
? last.content
: [{ type: 'text', text: last.content || '' }];
const newContent = Array.isArray(entry.content)
? entry.content
: [{ type: 'text', text: entry.content || '' }];
last.content = [...lastContent, ...newContent];
}
else {
result.push(entry);
}
};
let i = 0;
while (i < messages.length) {
const msg = messages[i];
if (msg.role === 'user') {
pushOrMerge({ role: 'user', content: msg.content || '' });
i++;
}
else if (msg.role === 'assistant') {
const content = [];
if (msg.content) {
content.push({ type: 'text', text: msg.content });
}
if (msg.toolCalls && msg.toolCalls.length > 0) {
for (const tc of msg.toolCalls) {
content.push({
type: 'tool_use',
id: tc.id,
name: tc.name,
input: tc.args || {},
});
}
}
pushOrMerge({
role: 'assistant',
content: content.length > 0 ? content : [{ type: 'text', text: '' }],
});
i++;
}
else if (msg.role === 'tool') {
// 收集连续 tool results → 合并为一个 user 消息
const toolResults = [];
while (i < messages.length && messages[i].role === 'tool') {
toolResults.push({
type: 'tool_result',
tool_use_id: messages[i].toolCallId || '',
content: messages[i].content || '',
});
i++;
}
pushOrMerge({ role: 'user', content: toolResults });
}
else {
i++; // skip unknown roles
}
}
return result;
}
/**
* 解析 Anthropic Messages API 响应 — 提取 tool_use 或 text
*
* Anthropic 返回格式:
* content[]: { type: 'text', text } | { type: 'tool_use', id, name, input }
* stop_reason: 'end_turn' | 'tool_use' | 'max_tokens'
*/
#parseToolResponse(data) {
// 提取 token 用量 (Claude usage)
const usage = data?.usage
? {
inputTokens: data.usage.input_tokens || 0,
outputTokens: data.usage.output_tokens || 0,
totalTokens: (data.usage.input_tokens || 0) + (data.usage.output_tokens || 0),
}
: null;
if (!data?.content?.length) {
return { text: '', functionCalls: null, usage };
}
const functionCalls = [];
const textParts = [];
for (const block of data.content) {
if (block.type === 'tool_use') {
functionCalls.push({
id: block.id,
name: block.name,
args: block.input || {},
});
}
else if (block.type === 'text') {
textParts.push(block.text);
}
}
if (functionCalls.length > 0) {
this.logger?.debug(`[Claude] native function calls: ${functionCalls.map((fc) => fc.name).join(', ')}`);
return {
text: textParts.length > 0 ? textParts.join('\n') : null,
functionCalls,
usage,
};
}
return {
text: textParts.join('\n'),
functionCalls: null,
usage,
};
}
async summarize(code) {
const prompt = `请对以下代码生成结构化摘要,返回 JSON 格式 {title, description, language, patterns: [], keyAPIs: []}:\n\n${code}`;
return ((await this.chatWithStructuredOutput(prompt, {
temperature: 0.3,
maxTokens: 4096,
})) || { title: '', description: '' });
}
async embed(_text) {
// Claude 不支持嵌入 API,返回空数组触发降级
return [];
}
supportsEmbedding() {
return false;
}
async _post(url, body, externalSignal) {
if (!this.apiKey) {
const err = new Error('Claude API Key 未配置。请在 .env 中设置 ASD_CLAUDE_API_KEY,或运行 asd setup 完成配置。');
err.code = 'API_KEY_MISSING';
throw err;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeout);
// 外部中止信号 → 联动本地 controller
const onExternalAbort = () => controller.abort();
externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
try {
const res = await this._fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': ANTHROPIC_VERSION,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
const err = new Error(`Claude API error: ${res.status}`);
err.status = res.status;
throw err;
}
return (await res.json());
}
finally {
clearTimeout(timer);
externalSignal?.removeEventListener('abort', onExternalAbort);
}
}
}
export default ClaudeProvider;