UNPKG

solver-sdk

Version:

SDK for WorkAI API - AI-powered code analysis with WorkCoins billing system

1,067 lines 64.8 kB
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: