solver-sdk
Version:
SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system
1,067 lines • 64.8 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';
/**
* 🔧 WorkAI: Internal Persistent Connection Implementation
*/
class PersistentSSEConnectionImpl {
constructor(id, sessionId, closeCallback) {
this.isConnected = false;
this.lastPing = 0;
this.createdAt = Date.now();
this.eventHandlers = new Map();
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 3;
this.id = id;
this.sessionId = sessionId;
this.closeCallback = closeCallback;
this.readyPromise = new Promise((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
}
async waitForReady() {
return this.readyPromise;
}
async close(reason = 'client_request') {
if (!this.isConnected) {
return;
}
this.isConnected = false;
this.emit('close', { reason });
if (this.closeCallback) {
await this.closeCallback(reason);
}
}
on(event, callback) {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event).add(callback);
}
off(event, callback) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.delete(callback);
}
}
// Internal methods
markReady() {
this.isConnected = true;
this.reconnectAttempts = 0; // Reset on successful connection
this.resolveReady();
}
markFailed(error) {
this.isConnected = false;
this.rejectReady(error);
// Auto-reconnect with exponential backoff
this.attemptReconnect();
}
updatePing(timestamp) {
this.lastPing = timestamp;
this.emit('ping', { timestamp });
}
setReconnectCallback(callback) {
this.reconnectCallback = callback;
}
async attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`❌ Max reconnect attempts (${this.maxReconnectAttempts}) reached`);
return;
}
this.reconnectAttempts++;
const backoffDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempts - 1), 4000);
console.log(`🔄 Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} after ${backoffDelay}ms`);
this.emit('reconnecting', { attempt: this.reconnectAttempts });
await new Promise(resolve => setTimeout(resolve, backoffDelay));
if (this.reconnectCallback) {
try {
await this.reconnectCallback();
}
catch (error) {
console.error(`❌ Reconnect failed:`, error);
// Will retry again on next error
}
}
}
emit(event, data) {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.forEach(handler => {
try {
handler(data);
}
catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
}
}
/**
* 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}`);
}
},
debug: (message) => {
const debugLevel = this.options.debug;
if (debugLevel === 'verbose' || debugLevel === 'debug') {
console.log(`🔍 ${message}`);
}
}
};
this.options = options;
}
/**
* 🔧 WorkAI: Выполняет streaming GET запрос используя нативный fetch
* Используется для persistent SSE connections, где axios не работает
*
* @param url - Полный URL для запроса
* @param headers - HTTP заголовки
* @returns Promise<Response> с ReadableStream body
*
* Best practice from undici docs: Node 18+ has global fetch
*/
async fetchStream(url, headers) {
console.log(`[SDK] 🔌 fetchStream: Attempting fetch to ${url}`);
console.log(`[SDK] 🔌 fetchStream: globalThis.fetch available: ${typeof globalThis.fetch !== 'undefined'}`);
// Node 18+ has native fetch support
if (typeof globalThis.fetch !== 'undefined') {
console.log(`[SDK] 🔌 fetchStream: Using globalThis.fetch`);
try {
const response = await globalThis.fetch(url, {
method: 'GET',
headers
});
console.log(`[SDK] ✅ fetchStream: Response received, status: ${response.status}, hasBody: ${!!response.body}`);
return response;
}
catch (error) {
console.error(`[SDK] ❌ fetchStream: globalThis.fetch failed:`, error.message);
throw error;
}
}
// Fallback для Node 16 через dynamic import
console.log(`[SDK] 🔌 fetchStream: globalThis.fetch not available, trying node-fetch`);
try {
const { default: nodeFetch } = await import('node-fetch');
console.log(`[SDK] ✅ fetchStream: node-fetch imported`);
const response = await nodeFetch(url, {
method: 'GET',
headers
});
console.log(`[SDK] ✅ fetchStream: node-fetch response received, status: ${response.status}`);
return response;
}
catch (error) {
console.error(`[SDK] ❌ fetchStream: node-fetch failed:`, error.message);
throw new Error(`Fetch API unavailable: ${error.message}. Please use Node.js 18+ or install node-fetch`);
}
}
/**
* Отправляет сообщение в чат и получает ответ от модели
* @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)`);
// WorkAI: Убрали SDK_HTTP_REQUEST - виден через debug логи если нужно
// Для диагностики включить debug: 'verbose' в SDK options
// ИСПРАВЛЕНИЕ: Используем postStream вместо общего request метода
const response = await this.httpClient.postStream(endpoint, params);
// WorkAI: Убрали SDK_HTTP_RESPONSE - успех видно по началу стрима
// Ошибки логируются через this.logger.error в catch блоках
// ИСПРАВЛЕНИЕ: Простой подход - обрабатываем как текст с 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();
// WorkAI: Убрали SDK_STREAM_START - дублирует информацию, спамит
// 🔧 FIX: Буфер для накопления неполных SSE строк
let lineBuffer = '';
let yieldCounter = 0; // 🔴 ДИАГНОСТИКА: Счетчик yield'ов
let totalBytesReceived = 0; // WorkAI: Трекинг общего объема для summary
let lastSummaryYield = 0; // WorkAI: Последний yield когда логировали summary
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// WorkAI: SDK_STREAM_END только в development - полезен для диагностики производительности
if (process.env.NODE_ENV === 'development') {
console.log(`🏁 [SDK_STREAM_END] Stream done | yielded ${yieldCounter} chunks | total ${totalBytesReceived} bytes`);
}
break;
}
const chunk = decoder.decode(value, { stream: true });
// 🔧 FIX: Добавляем к буферу, а не парсим сразу
lineBuffer += chunk;
totalBytesReceived += chunk.length;
// WorkAI: Убрали SDK_NETWORK_CHUNK - спамит в production
// Информация о потоке видна через SDK_STREAM_ACTIVE summary
// Ищем полные строки (заканчиваются на \n)
const lines = lineBuffer.split('\n');
// 🔧 FIX: Последняя "строка" может быть неполной - сохраняем в буфер
lineBuffer = lines.pop() || '';
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
});
// WorkAI: Счетчик yield'ов для внутренней статистики
yieldCounter++;
// WorkAI: Summary лог каждые 100 chunks (было 50) - уменьшили спам в production
// Показывает что поток активен, но не так часто
if (yieldCounter % 100 === 0 && process.env.NODE_ENV === 'development') {
console.log(`📊 [SDK_STREAM_ACTIVE] Yielded ${yieldCounter} chunks | ${totalBytesReceived} bytes total`);
}
// WorkAI: Убрали SDK_YIELD логи - они дублируют клиентские логи (THINKING_DELTA/TEXT_DELTA)
// и спамят в production при каждом запросе (584 chunks = ~12 логов)
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) {
// 🔴 КРИТИЧНО: Логируем SDK ошибки для диагностики network/stream issues
console.error(`❌ [SDK_STREAM_ERROR]`, 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);
}
/**
* 🔧 WorkAI: Send continuation via persistent SSE connection
* Uses existing connection instead of creating new one
*/
async *sendContinuationViaPersistent(connection, messages, options) {
if (!connection.isConnected) {
throw new Error('Persistent SSE connection is not active');
}
this.logger.info(`🔌 [CONTINUATION_VIA_PERSISTENT] Using connection: ${connection.id}`);
try {
const params = this.buildRequestParams(messages, {
...options,
stream: true,
clientRequestId: connection.id,
});
params.beta = 'interleaved-thinking-2025-05-14';
const response = await this.httpClient.post('/api/v1/chat/continue', params);
this.logger.info(`✅ [CONTINUATION_VIA_PERSISTENT] Request sent, waiting for events via persistent SSE`);
// Events will arrive via the persistent SSE connection
// We need to yield them from connection, but connection doesn't expose events yet
// For now, return empty - будет реализовано когда connection будет слушать continuation события
// TODO: Implement event listening on PersistentSSEConnection
this.logger.warn('⚠️ [CONTINUATION_VIA_PERSISTENT] Event listening not yet implemented');
}
catch (error) {
this.logger.error(`❌ [CONTINUATION_VIA_PERSISTENT] Error: ${error.message}`);
throw error;
}
}
/**
* Отправляет 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();
// 🔧 FIX: Буфер для накопления неполных SSE строк
let lineBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
this.logger.info('Continuation поток завершен');
break;
}
// Декодируем Uint8Array в строку
const chunk = decoder.decode(value, { stream: true });
// 🔧 FIX: Добавляем к буферу, а не парсим сразу
lineBuffer += chunk;
// Ищем полные строки (заканчиваются на \n)
const lines = lineBuffer.split('\n');
// 🔧 FIX: Последняя "строка" может быть неполной - сохраняем в буфер
lineBuffer = lines.pop() || '';
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();
// 🔧 FIX: Буфер для накопления неполных SSE строк
let lineBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
const chunk = decoder.decode(value, { stream: true });
// 🔧 FIX: Добавляем к буферу, а не парсим сразу
lineBuffer += chunk;
// Ищем полные строки (заканчиваются на \n)
const lines = lineBuffer.split('\n');
// 🔧 FIX: Последняя "строка" может быть неполной - сохраняем в буфер
lineBuffer = lines.pop() || '';
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;
}
// 🔧 WorkAI: Добавляем clientRequestId для поддержки отмены запросов
if (options?.clientRequestId) {
params.clientRequestId = options.clientRequestId;
}
// Добавляем 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)
});
}
}
}
/**
* 🔌 Pre-subscribe к SSE для получения continuation response
*
* Решает race condition: клиент подключается к SSE ПЕРЕД отправкой tool_result,
* гарантируя получение continuation response.
*
* Flow:
* 1. await subscribeToResponse(requestId) - подключение к SSE
* 2. await sendContinuation(messages) - отправка tool_result
* 3. for await (const chunk of subscription) - получение response
*
* @param clientRequestId - ID запроса клиента (передается в tool_result)
* @param options - Опции подключения
* @returns AsyncGenerator SSE events
*/
async *subscribeToResponse(clientRequestId, options) {
if (!clientRequestId) {
throw new Error('clientRequestId is required for subscribeToResponse');
}
// 🔧 WorkAI: If persistent connection provided, use it instead
if (options.persistentConnection && options.persistentConnection.isConnected) {
this.logger.info(`🔌 [PRE_SUBSCRIBE] Using persistent connection: ${clientRequestId}`);
// TODO: Yield events from persistent connection
// For now, fallback to direct subscription
this.logger.warn(`⚠️ [PRE_SUBSCRIBE] Persistent connection event streaming not yet implemented`);
}
const endpoint = `/api/v1/chat/subscribe?clientRequestId=${encodeURIComponent(clientRequestId)}`;
this.logger.info(`🔌 [PRE_SUBSCRIBE] Connecting to SSE: ${clientRequestId}`);
// 🔧 WorkAI: Retry logic for "No response body" errors
let lastError = null;
const maxRetries = 1;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
this.logger.warn(`🔄 [RETRY_SUBSCRIBE] Attempt ${attempt + 1}/${maxRetries + 1} after 500ms`);
await new Promise(resolve => setTimeout(resolve, 500));
}
try {
// 🔧 WorkAI: Используем нативный fetch для SSE (axios не поддерживает ReadableStream)
// Получаем auth token через httpClient (как в openPersistentSSE)
let authToken = null;
if (typeof this.httpClient.options?.getAuthToken === 'function') {
try {
authToken = await Promise.resolve(this.httpClient.options.getAuthToken());
}
catch (error) {
this.logger.warn(`⚠️ [PRE_SUBSCRIBE] Failed to get auth token: ${error.message}`);
}
}
// Формируем полный URL (baseURL + endpoint)
const baseURL = this.httpClient.getBaseURL?.() || 'http://localhost:3000';
const fullUrl = `${baseURL}${endpoint}`;
const response = await fetch(fullUrl, {
method: 'GET',
headers: {
'X-Project-ID': options.projectId,
'X-Client-Ready': 'true',
...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}),
},
});
// 🔍 DEBUG: Log response details
this.logger.debug(`🔌 [PRE_SUBSCRIBE] Response status: ${response.status}`);
this.logger.debug(`🔌 [PRE_SUBSCRIBE] Response ok: ${response.ok}`);
this.logger.debug(`🔌 [PRE_SUBSCRIBE] Response body present: ${!!response.body}`);
// Check for errors
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Subscribe endpoint returned ${response.status}: ${errorText}`);
}
if (!response.body) {
this.logger.error(`❌ [PRE_SUBSCRIBE] No response body! Full response: ${JSON.stringify({
status: response.status,
statusText: response.statusText,
bodyType: typeof response.body,
})}`);
throw new Error('No response body from subscribe endpoint');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let subscribed = false;
// 🔧 FIX: Буфер для накопления неполных SSE строк
let lineBuffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
this.logger.info(`🔌 [PRE_SUBSCRIBE] SSE stream ended: ${clientRequestId}`);
break;
}
const chunk = decoder.decode(value, { stream: true });
// 🔧 FIX: Добавляем к буферу, а не парсим сразу
lineBuffer += chunk;
// Ищем полные строки (заканчиваются на \n)
const lines = lineBuffer.split('\n');
// 🔧 FIX: Последняя "строка" может быть неполной - сохраняем в буфер
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: '))
continue;
const data = line.slice(6).trim();
if (!data)
continue;
try {
const event = JSON.parse(data);
// Callback when subscribed
if (event.type === 'subscribed' && !subscribed) {
subscribed = true;
this.logger.info(`🔌 [PRE_SUBSCRIBE] Subscribed successfully: ${clientRequestId}`);
if (options.onSubscribed) {
options.onSubscribed();
}
}
// Skip heartbeat events
if (event.type === 'heartbeat') {
continue;
}
// Timeout event
if (event.type === 'timeout') {
this.logger.warn(`🔌 [PRE_SUBSCRIBE] Timeout: ${clientRequestId}`);
break;
}
// Convert to ChatStreamChunk and yield
const streamChunk = {
type: event.type,
...event,
};
yield streamChunk;
// End on message_stop
if (event.type === 'message_stop') {
this.logger.info(`🔌 [PRE_SUBSCRIBE] Message stop received: