solver-sdk
Version:
SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system
711 lines • 37.7 kB
JavaScript
import { handleStreamError, processStreamChunk } from './stream-utils';
import { ANTHROPIC_CONSTANTS } from '../../constants/anthropic'; // 🔧 ДОБАВЛЕНО: импорт константы
import { ChatCancelMethods } from './cancel-methods';
// Простая функция генерации ID
function generateId(length = 10) {
return Math.random().toString(36).substring(2, 2 + length);
}
// Экспортируем все типы и интерфейсы для внешнего использования
export * from './models';
export * from './interfaces';
/**
* API для работы с чатом с поддержкой Anthropic Extended Thinking
*/
export class ChatApi extends ChatCancelMethods {
/**
* Создает новый экземпляр API для работы с чатом
* @param {IHttpClient} httpClient HTTP клиент
* @param {ChatApiOptions} options Опции логирования (опционально)
*/
constructor(httpClient, options = {}) {
super(httpClient); // Инициализируем ChatCancelMethods
/** Logger для ChatApi с настраиваемыми уровнями */
this.logger = {
streamEvent: (message, data) => {
const debugLevel = this.options.debug;
const streamLogging = this.options.streamLogging;
if (debugLevel === 'silent')
return;
if (streamLogging?.sseEvents || (debugLevel === 'verbose' || debugLevel === 'debug')) {
console.log(`🔍 SSE Event: ${message}`, data);
}
},
streamChunk: (message, data) => {
const debugLevel = this.options.debug;
const streamLogging = this.options.streamLogging;
if (debugLevel === 'silent')
return;
if (streamLogging?.streamChunks || (debugLevel === 'verbose' || debugLevel === 'debug')) {
console.log(`🔍 [CHAT-API] Получен chunk: ${message}`, data);
}
},
eventCallback: (message, data) => {
const debugLevel = this.options.debug;
const streamLogging = this.options.streamLogging;
if (debugLevel === 'silent')
return;
if (streamLogging?.eventCallbacks || (debugLevel === 'verbose' || debugLevel === 'debug')) {
console.log(`📞 [CHAT-API] Вызываем onEvent: ${message}`, data);
}
},
info: (message) => {
const debugLevel = this.options.debug;
if (debugLevel && debugLevel !== 'silent' && debugLevel !== 'error' && debugLevel !== 'warn') {
console.log(`🔧 ${message}`);
}
},
warn: (message) => {
const debugLevel = this.options.debug;
if (debugLevel !== 'silent') {
console.warn(`⚠️ ${message}`);
}
},
error: (message) => {
const debugLevel = this.options.debug;
if (debugLevel !== 'silent') {
console.error(`❌ ${message}`);
}
}
};
this.options = options;
}
/**
* Отправляет сообщение в чат и получает ответ от модели
* @param {ChatMessage[]} messages Массив сообщений для отправки
* @param {ChatOptions} [options] Дополнительные параметры
* @returns {Promise<ChatResponse>} Ответ модели
*/
async chat(messages, options) {
if (!messages || messages.length === 0) {
throw new Error('Необходимо предоставить хотя бы одно сообщение');
}
// Проверяем наличие хотя бы одного сообщения от пользователя
const hasUserMessage = messages.some(msg => msg.role === 'user');
if (!hasUserMessage) {
throw new Error('В сообщениях должно быть хотя бы одно сообщение с ролью "user"');
}
// Валидация параметров согласно документации Anthropic
this.validateChatOptions(options);
// Подготавливаем параметры запроса
const requestParams = this.buildRequestParams(messages, options);
// Отправляем запрос к API чата
return this.httpClient.post('/api/v1/chat', requestParams);
}
/**
* Алиас для метода chat для совместимости с другими SDK
* @param {ChatMessage[]} messages Массив сообщений для отправки
* @param {ChatOptions} [options] Дополнительные параметры
* @returns {Promise<ChatResponse>} Ответ модели
*/
async chatCompletion(messages, options) {
return this.chat(messages, options);
}
/**
* Проверяет доступность API чата
* @returns {Promise<boolean>} Результат проверки
*/
async checkAvailability() {
try {
// Проверяем доступность через простой POST запрос с минимальными данными
await this.httpClient.post('/api/v1/chat', {
messages: [{ role: 'user', content: 'ping' }],
model: ANTHROPIC_CONSTANTS.DEFAULT_MODEL, // 🔧 ИСПРАВЛЕНО: используем константу
max_tokens: 1
});
return true;
}
catch (error) {
// Любая ошибка означает недоступность (в том числе 500, 400 и т.д.)
return false;
}
}
/**
* Отправляет сообщение в чат и получает ответ от модели
* с автоматическим переключением между регионами при ошибках перегрузки
* @param {ChatMessage[]} messages Массив сообщений для отправки
* @param {ChatOptions} [options] Дополнительные параметры
* @returns {Promise<ChatResponse>} Ответ модели
*/
async chatWithRegionFailover(messages, options) {
// Список всех доступных регионов
const allRegions = ['us-east-1', 'eu-west-1', 'ap-southeast-2'];
// Начинаем с региона из параметров, или первого в списке
let startRegionIndex = 0;
if (options?.region) {
const regionIndex = allRegions.indexOf(options.region);
if (regionIndex !== -1) {
startRegionIndex = regionIndex;
}
}
// Реорганизуем массив, чтобы начать с указанного региона
const regions = [
...allRegions.slice(startRegionIndex),
...allRegions.slice(0, startRegionIndex)
];
// Последняя ошибка, будет возвращена если все регионы недоступны
let lastError = null;
// Пробуем каждый регион по очереди
for (let i = 0; i < regions.length; i++) {
const region = regions[i];
try {
console.log(`Попытка запроса к Anthropic API в регионе ${region}`);
// Копируем опции и устанавливаем текущий регион
const regionOptions = {
...options,
region
};
// Отправляем запрос с конкретным регионом
return await this.chat(messages, regionOptions);
}
catch (error) {
lastError = error;
// Проверяем, является ли ошибка ошибкой перегрузки (код 529)
const isOverloadError = error.status === 529 ||
error.code === 529 ||
(error.response?.status === 529) ||
(error.message && error.message.includes('overloaded')) ||
(error.error?.type === 'overloaded_error');
if (isOverloadError) {
console.warn(`Регион ${region} перегружен, пробуем следующий регион`);
// Продолжаем цикл и пробуем следующий регион
continue;
}
else {
// Если ошибка не связана с перегрузкой, прекращаем попытки
console.error(`Ошибка в регионе ${region}, не связанная с перегрузкой:`, error);
throw error;
}
}
}
// Если мы здесь, значит все регионы перегружены
throw lastError || new Error('Все регионы Anthropic API перегружены, попробуйте позже');
}
/**
* Отправляет одиночный запрос к модели с автоматическим переключением регионов
* @param {string} prompt Запрос к модели
* @param {ChatOptions} [options] Дополнительные параметры
* @returns {Promise<string>} Текстовый ответ модели
*/
async sendPromptWithRegionFailover(prompt, options) {
const messages = [
{ role: 'user', content: prompt }
];
const response = await this.chatWithRegionFailover(messages, options);
// Извлекаем текст из ответа
if (response.content && response.content.length > 0) {
const textBlocks = response.content.filter(block => block.type === 'text');
if (textBlocks.length > 0) {
return textBlocks.map(block => block.text).join('');
}
}
throw new Error('Модель не вернула текстовый ответ');
}
/**
* Потоковый чат с поддержкой thinking
* @param {ChatMessage[]} messages Массив сообщений для отправки
* @param {ChatStreamOptions} [options] Дополнительные параметры
* @returns {AsyncGenerator<ChatStreamChunk>} Асинхронный генератор чанков ответа
*/
async *streamChat(messages, options) {
// Простая валидация
if (!messages || messages.length === 0) {
throw new Error('Нет сообщений для отправки');
}
this.logger.info(`Отправляем ${messages.length} сообщений в чат...`);
try {
// Создаем параметры запроса
const params = this.buildRequestParams(messages, {
...options,
stream: true
});
// ✅ Backend автоматически добавляет interleaved thinking для инструментов
const endpoint = '/api/v1/chat/stream';
this.logger.info(`Используем эндпоинт: ${endpoint} (unified interleaved thinking)`);
// ИСПРАВЛЕНИЕ: Используем postStream вместо общего request метода
const response = await this.httpClient.postStream(endpoint, params);
// ИСПРАВЛЕНИЕ: Простой подход - обрабатываем как текст с Node.js stream
if (!response.body) {
throw new Error('Некорректный ответ от сервера для потоковой передачи');
}
// В Node.js и браузере используем одинаковый Web API подход
if (!response.body || typeof response.body.getReader !== 'function') {
throw new Error('Ответ не содержит ReadableStream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
// Игнорируем пустые строки
if (!line.trim()) {
continue;
}
// ✅ ТОЛЬКО ОФИЦИАЛЬНЫЕ СОБЫТИЯ: Используем переписанный processStreamChunk
const result = processStreamChunk(line, this.logger, {
onVSCodeCommand: options?.onVSCodeCommand,
ignoreInvalidCommands: true
});
// Если есть чанк для отправки
if (result && result.chunk) {
// 🔍 ДИАГНОСТИКА: логируем ВСЕ типы событий для анализа
// 🔇 УБРАНО: избыточное диагностическое логирование
// 🔇 УБРАНО: избыточная диагностика thinking_delta
this.logger.streamChunk('', {
type: result.chunk.type,
hasContentBlock: !!result.chunk.content_block,
hasDelta: !!result.chunk.delta,
hasOnEvent: !!options?.onEvent
});
yield result.chunk;
}
// ✅ ОФИЦИАЛЬНАЯ ОБРАБОТКА СОБЫТИЙ ANTHROPIC API
if (options && options.onEvent && line.startsWith('data: ') && line.slice(6).trim() !== '') {
try {
const originalEventData = JSON.parse(line.slice(6).trim());
this.logger.eventCallback('Передаем оригинальные данные события в onEvent', {
eventType: originalEventData.type,
hasContentBlock: !!originalEventData.content_block,
hasDelta: !!originalEventData.delta
});
// ✅ ТОЛЬКО ОРИГИНАЛЬНЫЕ ДАННЫЕ: согласно стандарту Anthropic API
options.onEvent(originalEventData.type, originalEventData);
// Завершаем поток при получении message_stop события
if (originalEventData.type === 'message_stop') {
break;
}
}
catch (e) {
// Если не удалось распарсить оригинальные данные - просто пропускаем
this.logger.eventCallback('Пропускаем событие с ошибкой парсинга', {
error: e instanceof Error ? e.message : String(e),
line: line.substring(0, 100)
});
}
}
}
}
}
finally {
reader.releaseLock();
}
// Вызываем onComplete если передан
if (options?.onComplete) {
options.onComplete('Stream completed');
}
}
catch (error) {
if (options?.onError) {
options.onError(error);
}
throw handleStreamError(error);
}
}
/**
* Отправляет одиночный запрос в потоковом режиме
* @param {string} prompt Запрос к модели
* @param {ChatStreamOptions} [options] Дополнительные параметры
* @returns {AsyncGenerator<ChatStreamChunk>} Асинхронный генератор чанков ответа
*/
async *streamPrompt(prompt, options) {
// Преобразуем простой prompt в массив сообщений
const messages = [{ role: 'user', content: prompt }];
yield* this.streamChat(messages, options);
}
/**
* Отправляет continuation запрос для interleaved thinking
*/
async *sendContinuation(messages, options) {
// Валидация continuation запроса
if (messages.length < 3) {
throw new Error('Continuation требует минимум 3 сообщения для interleaved thinking');
}
// Проверяем структуру последнего сообщения
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'user' || !Array.isArray(lastMessage.content)) {
throw new Error('Continuation требует минимум 3 сообщения: user → assistant (thinking + tool_use) → user (tool_result)');
}
// ✅ КРИТИЧНО: НЕ ВАЛИДИРУЕМ continuation messages!
// Thinking блоки УЖЕ от Anthropic API и НЕ ДОЛЖНЫ быть модифицированы!
// Документация: "thinking blocks cannot be modified. These blocks must remain as they were in the original response."
const validatedMessages = messages; // Передаем как есть, БЕЗ валидации!
this.logger.info(`Отправляем continuation запрос с ${validatedMessages.length} сообщениями...`);
try {
// Создаем параметры запроса с ВАЛИДИРОВАННЫМИ сообщениями
const params = this.buildRequestParams(validatedMessages, {
...options,
stream: true
});
// КРИТИЧНО: Continuation всегда с interleaved thinking
params.beta = 'interleaved-thinking-2025-05-14';
// ✅ ИСПРАВЛЕНИЕ: Используем специальный continuation endpoint
const endpoint = '/api/v1/chat/continue';
this.logger.info(`Используем continuation endpoint: ${endpoint} (НЕ /chat/stream!)`);
// Используем postStream для continuation endpoint
const response = await this.httpClient.postStream(endpoint, params);
// Обрабатываем поток точно так же как в streamChat
if (!response.body) {
throw new Error('Некорректный ответ от сервера для потоковой передачи');
}
// В Node.js и браузере используем одинаковый Web API подход
if (!response.body || typeof response.body.getReader !== 'function') {
throw new Error('Ответ не содержит ReadableStream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
this.logger.info('Continuation поток завершен');
break;
}
// Декодируем Uint8Array в строку
const chunk = decoder.decode(value, { stream: true });
// Разбиваем на отдельные строки (SSE может содержать несколько событий в одном чанке)
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim() === '')
continue;
const result = processStreamChunk(line.trim(), this.logger, options);
if (result.chunk) {
// 🔍 ДИАГНОСТИКА: логируем ВСЕ типы событий в continuation
// 🔇 УБРАНО: избыточное диагностическое логирование
// 🔇 УБРАНО: избыточная диагностика thinking_delta в continuation
this.logger.streamChunk('Обрабатываем chunk', result.chunk);
// Вызываем callback events
if (options?.onEvent) {
this.logger.eventCallback(`Callback для ${result.chunk.type}`, result.chunk);
options.onEvent(result.chunk.type, result.chunk);
}
yield result.chunk;
}
}
}
}
finally {
reader.releaseLock();
}
}
catch (error) {
this.logger.error(`Ошибка continuation запроса: ${error.message}`);
throw error;
}
}
/**
* 🔄 Продолжает прерванный ответ (resume после timeout)
* @param {string} originalRequestId ID оригинального запроса
* @param {ChatMessage[]} messages История сообщений
* @param {string} partialText Частично полученный текст
* @param {ChatStreamOptions} [options] Дополнительные параметры
* @returns {AsyncGenerator<ChatStreamChunk>} Асинхронный генератор чанков
*/
async *resumeChat(originalRequestId, messages, partialText, options) {
this.logger.info(`Отправляем resume запрос для ${originalRequestId} (partial: ${partialText.length} chars)`);
try {
const params = {
...this.buildRequestParams(messages, { ...options, stream: true }),
originalRequestId,
partialContent: {
text: partialText,
length: partialText.length,
},
};
const endpoint = '/api/v1/chat/resume';
const response = await this.httpClient.postStream(endpoint, params);
if (!response.body || typeof response.body.getReader !== 'function') {
throw new Error('Ответ не содержит ReadableStream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.trim())
continue;
const result = processStreamChunk(line, this.logger, options);
if (result && result.chunk) {
this.logger.streamChunk('Resume chunk', result.chunk);
if (options?.onEvent) {
options.onEvent(result.chunk.type, result.chunk);
}
yield result.chunk;
}
}
}
}
finally {
reader.releaseLock();
}
}
catch (error) {
this.logger.error(`Ошибка resume запроса: ${error.message}`);
throw error;
}
}
/**
* Валидирует опции чата согласно документации Anthropic
* @private
*/
validateChatOptions(options) {
if (!options)
return;
// ✅ ИСПРАВЛЕНИЕ: Правильные валидации согласно официальной документации Anthropic
if (options.thinking) {
// 1. temperature несовместим с thinking
if (options.temperature !== undefined) {
throw new Error('Параметр temperature несовместим с extended thinking согласно документации Anthropic');
}
// 2. top_p с thinking должен быть между 0.95 и 1.0
if (options.top_p !== undefined) {
if (options.top_p < 0.95 || options.top_p > 1.0) {
throw new Error('При использовании thinking параметр top_p должен быть между 0.95 и 1.0 согласно документации Anthropic');
}
}
// 3. tool_choice ограничения с thinking
if (options.tools && options.tool_choice) {
const choice = options.tool_choice;
if (options.thinking) {
// 🧠 НОВАЯ ДОКУМЕНТАЦИЯ ANTHROPIC: С thinking поддерживается ТОЛЬКО { type: "any" }
const isValidChoice = choice === 'any' ||
(typeof choice === 'object' && choice?.type === 'any');
if (!isValidChoice) {
this.logger.warn('tool_choice для thinking убран согласно документации Anthropic. ' +
'С расширенным мышлением поддерживается ТОЛЬКО { type: "any" }. ' +
`Исходное значение: ${JSON.stringify(choice)}`);
delete options.tool_choice; // Убираем недопустимый choice
}
}
else {
// Без thinking - стандартные ограничения
if (choice === 'any' ||
(typeof choice === 'object' && (choice?.type === 'any' || choice?.type === 'tool'))) {
throw new Error('tool_choice: "any", { type: "any" }, { type: "tool" } поддерживаются ТОЛЬКО с thinking режимом. ' +
'Без thinking используйте: "auto", "none" или не указывайте tool_choice');
}
}
}
}
// Валидация бюджета токенов (только если задан явно)
if (options.thinking && typeof options.thinking === 'object') {
if (options.thinking.budget_tokens !== undefined && options.thinking.budget_tokens < 1024) {
throw new Error('Минимальный budget_tokens для thinking составляет 1024');
}
}
// Валидация max_tokens для потоковой передачи
if (options.max_tokens && options.max_tokens > 21333 && !options.stream) {
console.warn('При max_tokens > 21333 рекомендуется использовать потоковую передачу');
}
}
/**
* Подготавливает параметры запроса для отправки на сервер
* @param {ChatMessage[]} messages Сообщения
* @param {ChatOptions} options Опции
* @returns {Record<string, any>} Параметры запроса
*/
buildRequestParams(messages, options) {
const params = {
messages,
model: options?.model || ANTHROPIC_CONSTANTS.DEFAULT_MODEL,
// ✅ ИСПРАВЛЕНО: max_tokens НЕ передаётся если клиент не указал явно
// Сервер автоматически рассчитает оптимальное значение (32K для tools, 16K для текста)
};
// Добавляем max_tokens только если клиент явно указал
if (options?.max_tokens !== undefined) {
params.max_tokens = options.max_tokens;
}
// Добавляем температуру только если указана
if (options?.temperature !== undefined) {
params.temperature = options.temperature;
}
// Обрабатываем thinking конфигурацию согласно Anthropic API
if (options?.thinking) {
if (typeof options.thinking === 'boolean') {
// Конвертация boolean в объект thinking
params.thinking = {
type: 'enabled',
budget_tokens: 4000 // Дефолтное значение
};
}
else if (typeof options.thinking === 'object') {
// Новая структура thinking
const thinkingConfig = {
type: 'enabled' // Всегда enabled для Anthropic API
};
// Используем budget_tokens из ThinkingConfig
if (options.thinking.budget_tokens) {
thinkingConfig.budget_tokens = options.thinking.budget_tokens;
}
else {
thinkingConfig.budget_tokens = 4000; // Дефолт
}
params.thinking = thinkingConfig;
// Устанавливаем temperature = 1.0 для thinking согласно документации
params.temperature = 1.0;
}
}
// Добавляем stream если указан
if (options?.stream !== undefined) {
params.stream = options.stream;
}
// Добавляем инструменты если указаны
if (options?.tools && Array.isArray(options.tools)) {
params.tools = options.tools;
}
// Добавляем tool_choice если указан
if (options?.tool_choice !== undefined) {
params.tool_choice = options.tool_choice;
}
// Добавляем beta если указан
if (options?.beta) {
params.beta = options.beta;
}
// Добавляем projectId если указан
if (options?.projectId) {
params.projectId = options.projectId;
}
// Добавляем socketId если указан
if (options?.socketId) {
params.socketId = options.socketId;
}
// Добавляем region если указан
if (options?.region) {
params.region = options.region;
}
// ======== CURSOR РЕЖИМЫ ========
// Добавляем режим если указан
if (options?.mode) {
params.mode = options.mode;
}
// ======== ENRICHMENT CONTEXT ========
// Пробрасываем все дополнительные поля для обогащения контекста
// (openFiles, recentFiles, filePath, selection, enableEnrichment, sessionId, etc.)
const handledKeys = [
// Явно обработанные параметры модели
'model', 'temperature', 'max_tokens', 'thinking', 'stream', 'tools',
'tool_choice', 'beta', 'projectId', 'socketId', 'region', 'mode',
'stop_sequences', 'top_p', 'system', 'requestId', 'messages',
// Callback функции (НЕЛЬЗЯ передавать на бекенд - не сериализуются)
'onToken', 'onComplete', 'onError', 'onToolUse', 'onEvent',
// Клиентские служебные флаги
'authToken', 'autoExecuteTools'
];
if (options) {
for (const key of Object.keys(options)) {
if (!handledKeys.includes(key) && options[key] !== undefined) {
params[key] = options[key];
}
}
}
return params;
}
/**
* Конвертирует ответ API в ChatStreamChunk (только официальные поля Anthropic API)
* @private
*/
convertToStreamChunk(data) {
if (!data || !data.type)
return null;
// ✅ ТОЛЬКО официальные события Anthropic API
const validEvents = [
'message_start', 'content_block_start', 'content_block_delta',
'content_block_stop', 'message_delta', 'message_stop', 'ping', 'error'
];
if (!validEvents.includes(data.type)) {
// Игнорируем неофициальные события
return null;
}
const chunk = {
type: data.type
};
// Добавляем поля в зависимости от типа события
switch (data.type) {
case 'message_start':
if (data.message) {
chunk.message = data.message;
}
break;
case 'content_block_start':
if (data.index !== undefined) {
chunk.index = data.index;
}
if (data.content_block) {
chunk.content_block = data.content_block;
}
break;
case 'content_block_delta':
if (data.index !== undefined) {
chunk.index = data.index;
}
if (data.delta) {
chunk.delta = data.delta;
}
break;
case 'content_block_stop':
if (data.index !== undefined) {
chunk.index = data.index;
}
break;
case 'message_delta':
if (data.delta) {
chunk.message_delta = data.delta;
}
break;
case 'error':
if (data.error) {
chunk.error = data.error;
}
break;
// message_stop и ping не требуют дополнительных полей
}
return chunk;
}
/**
* Обрабатывает автоматическое выполнение инструментов
* @private
*/
async handleAutoToolExecution(toolUse, options) {
try {
if (options.onToolUse) {
// Используем кастомный обработчик
await options.onToolUse(toolUse, options.projectId);
}
else {
// Выполняем через стандартный endpoint
const response = await this.httpClient.post('/tools/execute', {
name: toolUse.name,
input: toolUse.input,
projectId: options.projectId
});
// Здесь можно эмитить tool_result обратно через onEvent
if (options.onEvent) {
options.onEvent('tool_result', {
type: 'tool_result',
tool_use_id: toolUse.id,
data: response
});
}
}
}
catch (error) {
console.error('Ошибка автоматического выполнения инструмента:', error);
if (options.onEvent) {
options.onEvent('tool_error', {
type: 'tool_error',
tool_use_id: toolUse.id,
error: error instanceof Error ? error.message : String(error)
});
}
}
}
}
//# sourceMappingURL=index.js.map