UNPKG

solver-sdk

Version:

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

711 lines 37.7 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'; /** * 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