UNPKG

@nomyx/assistant

Version:

A powerful assistant library and cli for your AI projects. works with Vertex AI (Claude and Gemini)

252 lines (230 loc) 8.59 kB
import axios from 'axios'; import { ChatMessage, ChatOptions, ProviderResponse } from '../../../types/chat'; import { Tool } from '../../../types/tool'; import { OpenRouterConfig } from './config'; import { OpenRouterChatRequestBody, OpenRouterChatResponse, OpenRouterMessage, OpenRouterDelta, OpenRouterMessageContent, OpenRouterContentPart } from './types'; import { convertToolSchema, convertMessages, extractToolCalls } from './tools'; import { getMaxTokens, supportsTools } from './modelConfig'; function convertContentToOpenRouterFormat(content: string): string { return content; } export async function* streamChat( config: OpenRouterConfig, messages: ChatMessage[], options: ChatOptions, tools?: Tool[] ): AsyncIterableIterator<ProviderResponse> { const url = config.getApiUrl('chat/completions'); const model = config.model; const logger = config.getLogger(); const requestBody: OpenRouterChatRequestBody = { messages: convertMessages(messages).map(msg => ({ ...msg, content: convertContentToOpenRouterFormat(Array.isArray(msg.content) ? msg.content.map((part: any) => part.text).join(' ') : msg.content) })), model: model, max_tokens: options.maxTokens || getMaxTokens(model), temperature: options.temperature, stream: true }; const modelSupportsTools = supportsTools(model); if (tools && tools.length > 0) { if (modelSupportsTools) { requestBody.tools = tools.map(tool => convertToolSchema(tool.getSchema())); requestBody.tool_choice = 'auto'; } else { // Append instructions for using tools const toolInstructions = ` You have access to the following tools: ${tools.map(tool => `- ${tool.name}: ${tool.description}`).join('\n')} To use a tool, output a function call in a code block like this: \`\`\`function_call { "name": "tool_name", "arguments": { "arg1": "value1", "arg2": "value2" } } \`\`\` Replace "tool_name" with the actual tool name and provide the necessary arguments. For example, to use the "tool_name" tool with arguments "arg1" and "arg2": \`\`\`function_call { "name": "tool_name", "arguments": { "arg1": "value1", "arg2": "value2" } } \`\`\` `; requestBody.messages[requestBody.messages.length - 1].content += '\n\n' + toolInstructions; } } const headers = { ...config.getHeaders(), 'HTTP-Referer': config.httpReferer, 'X-Title': config.xTitle }; logger.debug('OpenRouter API streaming request', { url, model, toolsCount: tools?.length }); try { const response = await axios.post<NodeJS.ReadableStream>(url, requestBody, { headers, responseType: 'stream', }); console.clear(); logger.debug('OpenRouter API streaming response received'); let buffer = ''; let accumulatedMessage: OpenRouterMessage = { role: 'assistant', content: '', tool_calls: [] }; for await (const chunk of response.data) { buffer += chunk.toString(); let newlineIndex; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line.startsWith('data: ')) { const jsonData = line.slice(6); //logger.debug('Received JSON data', { jsonData }); if (jsonData === '[DONE]') { //logger.debug('End of stream detected'); return; } try { const data = JSON.parse(jsonData) as OpenRouterChatResponse; yield* createStreamingProviderResponse(data, modelSupportsTools, logger, accumulatedMessage); } catch (error) { logger.error('Error parsing JSON', { line, error }); } } } } } catch (error) { if (axios.isAxiosError(error)) { logger.error('OpenRouter API streaming error', { status: error.response?.status, statusText: error.response?.statusText, data: error.response?.data }); } else { logger.error('OpenRouter API streaming error', { error }); } throw error; } } function* createStreamingProviderResponse( data: OpenRouterChatResponse, modelSupportsTools: boolean, logger: ReturnType<OpenRouterConfig['getLogger']>, accumulatedMessage: OpenRouterMessage ): Generator<ProviderResponse> { const choice = data.choices[0]; if (!choice) { logger.error('Unexpected response format from OpenRouter API: No choices', { data }); throw new Error('Unexpected response format from OpenRouter API: No choices'); } if (choice.delta) { // Handle streaming format updateAccumulatedMessage(accumulatedMessage, choice.delta); } else if (choice.message) { // Handle non-streaming format accumulatedMessage = ensureValidOpenRouterMessage(choice.message); } else { logger.error('Unexpected response format from OpenRouter API: No delta or message in choice', { choice }); throw new Error('Unexpected response format from OpenRouter API: No delta or message in choice'); } let content = getContentAsString(accumulatedMessage.content); let toolCalls = modelSupportsTools ? extractToolCalls(accumulatedMessage) : []; if (!modelSupportsTools) { const functionCallRegex = /```function_call\n([\s\S]*?)```/g; let match; while ((match = functionCallRegex.exec(content)) !== null) { try { const functionCall = JSON.parse(match[1]); toolCalls.push({ name: functionCall.name, arguments: functionCall.arguments }); } catch (error) { logger.error('Error parsing function call', { error, match: match[1] }); } } content = content.replace(functionCallRegex, ''); } yield { content: content.trim(), toolCalls: toolCalls, usage: { promptTokens: data.usage?.prompt_tokens || 0, completionTokens: data.usage?.completion_tokens || 0, totalTokens: data.usage?.total_tokens || 0 }, }; } function updateAccumulatedMessage(accumulatedMessage: OpenRouterMessage, delta: OpenRouterDelta) { if (delta.role && (delta.role === 'assistant' || delta.role === 'user' || delta.role === 'system' || delta.role === 'tool')) { accumulatedMessage.role = delta.role; } if (delta.content) { if (typeof accumulatedMessage.content === 'string') { accumulatedMessage.content += delta.content; process.stdout.write(delta.content); } else if (Array.isArray(accumulatedMessage.content)) { if (accumulatedMessage.content.length === 0 || typeof accumulatedMessage.content[accumulatedMessage.content.length - 1] !== 'string') { accumulatedMessage.content.push({ type: 'text', text: delta.content }); } else { const lastPart = accumulatedMessage.content[accumulatedMessage.content.length - 1]; if (lastPart.type === 'text') { lastPart.text += delta.content; } else { accumulatedMessage.content.push({ type: 'text', text: delta.content }); } } } } if (delta.tool_calls) { if (!accumulatedMessage.tool_calls) { accumulatedMessage.tool_calls = []; } delta.tool_calls.forEach((toolCall, index) => { if (!accumulatedMessage.tool_calls![index]) { accumulatedMessage.tool_calls![index] = { id: '', type: 'function', function: { name: '', arguments: '' } }; } if (toolCall.id) { accumulatedMessage.tool_calls![index].id = toolCall.id; } if (toolCall.function) { if (toolCall.function.name) { accumulatedMessage.tool_calls![index].function.name = toolCall.function.name; } if (toolCall.function.arguments) { accumulatedMessage.tool_calls![index].function.arguments += toolCall.function.arguments; } } }); } } function getContentAsString(content: OpenRouterMessageContent): string { if (typeof content === 'string') { return content; } else if (Array.isArray(content)) { return content.map(part => { if (part.type === 'text') { return part.text; } else if (part.type === 'image_url') { return `[Image: ${part.image_url.url}]`; } return ''; }).join(' '); } return ''; } function ensureValidOpenRouterMessage(message: any): OpenRouterMessage { const validRoles: OpenRouterMessage['role'][] = ['assistant', 'user', 'system', 'tool']; const role = validRoles.includes(message.role) ? message.role : 'assistant'; return { role, content: message.content || '', tool_calls: message.tool_calls || [] }; }