mirror of
https://github.com/voideditor/void
synced 2026-05-23 09:28:23 +00:00
refactor: modularize LLM providers and types
- Split monolithic sendLLMMessage.ts into separate provider modules - Create dedicated files for each LLM provider (anthropic, gemini, openai, ollama, greptile) - Move types into separate types.ts file - Update import paths across affected files - No functional changes, purely architectural improvement This refactoring improves code organization and maintainability by: - Separating concerns for each LLM provider - Making the codebase more modular - Improving type management
This commit is contained in:
parent
791f6b1151
commit
aa6b5c6b6c
13 changed files with 537 additions and 387 deletions
49
extensions/void/src/common/llm/index.ts
Normal file
49
extensions/void/src/common/llm/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Import message sending functions for different LLM providers
|
||||
import { sendAnthropicMsg } from './providers/anthropic'
|
||||
import { sendGeminiMsg } from './providers/gemini'
|
||||
import { sendOpenAIMsg } from './providers/openai'
|
||||
import { sendOllamaMsg } from './providers/ollama'
|
||||
import { sendGreptileMsg } from './providers/greptile'
|
||||
import { LLMMessage, OnText, OnFinalMessage, AbortRef } from './types'
|
||||
import { VoidConfig } from '../../webviews/common/contextForConfig'
|
||||
|
||||
// Main function to send messages to LLM providers
|
||||
export const sendLLMMessage = ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig,
|
||||
abortRef
|
||||
}: {
|
||||
messages: LLMMessage[], // Array of messages to send
|
||||
onText: OnText, // Callback for receiving text chunks
|
||||
onFinalMessage: (fullText: string) => void, // Callback for final message
|
||||
onError: (error: string) => void, // Error handling callback
|
||||
voidConfig: VoidConfig | null, // Configuration object
|
||||
abortRef: AbortRef, // Reference for aborting requests
|
||||
}) => {
|
||||
// Return early if no config is provided
|
||||
if (!voidConfig) return
|
||||
|
||||
// Trim whitespace from all message contents
|
||||
messages = messages.map(m => ({ ...m, content: m.content.trim() }))
|
||||
|
||||
// Route message to appropriate provider based on configuration
|
||||
switch (voidConfig.default.whichApi) {
|
||||
case 'anthropic':
|
||||
return sendAnthropicMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef })
|
||||
case 'openAI':
|
||||
case 'openRouter':
|
||||
case 'openAICompatible':
|
||||
return sendOpenAIMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef })
|
||||
case 'gemini':
|
||||
return sendGeminiMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef })
|
||||
case 'ollama':
|
||||
return sendOllamaMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef })
|
||||
case 'greptile':
|
||||
return sendGreptileMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef })
|
||||
default:
|
||||
onError(`Error: whichApi was ${voidConfig.default.whichApi}, which is not recognized!`)
|
||||
}
|
||||
}
|
||||
66
extensions/void/src/common/llm/providers/anthropic.ts
Normal file
66
extensions/void/src/common/llm/providers/anthropic.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { SendLLMMessageParams, LLMMessageAnthropic } from '../types'
|
||||
import { parseMaxTokensStr } from '../utils'
|
||||
|
||||
export const sendAnthropicMsg = ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig
|
||||
}: SendLLMMessageParams) => {
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: voidConfig.anthropic.apikey,
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
|
||||
// Combine system messages into a single string
|
||||
const systemMessage = messages
|
||||
.filter(msg => msg.role === 'system')
|
||||
.map(msg => msg.content)
|
||||
.join('\n')
|
||||
|
||||
// Remove system messages and cast to Anthropic message type
|
||||
const anthropicMessages = messages
|
||||
.filter(msg => msg.role !== 'system') as LLMMessageAnthropic[]
|
||||
|
||||
let did_abort = false
|
||||
|
||||
const stream = anthropic.messages.stream({
|
||||
system: systemMessage,
|
||||
messages: anthropicMessages,
|
||||
model: voidConfig.anthropic.model,
|
||||
max_tokens: parseMaxTokensStr(voidConfig.default.maxTokens)!,
|
||||
})
|
||||
|
||||
// Handle streaming response
|
||||
stream.on('text', (newText, fullText) => {
|
||||
if (did_abort) return
|
||||
onText(newText, fullText)
|
||||
})
|
||||
|
||||
// Handle final message
|
||||
stream.on('finalMessage', (response) => {
|
||||
if (did_abort) return
|
||||
const content = response.content
|
||||
.map(c => c.type === 'text' ? c.text : '')
|
||||
.join('\n')
|
||||
onFinalMessage(content)
|
||||
})
|
||||
|
||||
// Handle errors
|
||||
stream.on('error', (error) => {
|
||||
if (error instanceof Anthropic.APIError && error.status === 401) {
|
||||
onError('Invalid API key.')
|
||||
} else {
|
||||
onError(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
abort: () => {
|
||||
did_abort = true
|
||||
stream.controller.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
68
extensions/void/src/common/llm/providers/gemini.ts
Normal file
68
extensions/void/src/common/llm/providers/gemini.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { GoogleGenerativeAI, GoogleGenerativeAIFetchError } from '@google/generative-ai'
|
||||
import { SendLLMMessageParams } from '../types'
|
||||
import { parseMaxTokensStr } from '../utils'
|
||||
|
||||
export const sendGeminiMsg = async ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig,
|
||||
abortRef
|
||||
}: SendLLMMessageParams) => {
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(voidConfig.gemini.apikey)
|
||||
const model = genAI.getGenerativeModel({ model: voidConfig.gemini.model })
|
||||
|
||||
// Get system messages and combine them
|
||||
const systemMessage = messages
|
||||
.filter(msg => msg.role === 'system')
|
||||
.map(msg => msg.content)
|
||||
.join('\n')
|
||||
|
||||
// Convert messages to Gemini format
|
||||
const geminiMessages = messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map(msg => ({
|
||||
parts: [{ text: msg.content }],
|
||||
role: msg.role === 'assistant' ? 'model' : 'user'
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await model.generateContentStream({
|
||||
contents: geminiMessages,
|
||||
systemInstruction: systemMessage,
|
||||
})
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
if (didAbort) return
|
||||
const newText = chunk.text()
|
||||
fullText += newText
|
||||
onText(newText, fullText)
|
||||
}
|
||||
|
||||
onFinalMessage(fullText)
|
||||
} catch (error) {
|
||||
if (error instanceof GoogleGenerativeAIFetchError) {
|
||||
if (error.status === 400) {
|
||||
onError('Invalid API key.')
|
||||
} else {
|
||||
onError(`${error.name}:\n${error.message}`)
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
onError(error.toString())
|
||||
} else {
|
||||
onError('Unknown error occurred')
|
||||
}
|
||||
}
|
||||
}
|
||||
98
extensions/void/src/common/llm/providers/greptile.ts
Normal file
98
extensions/void/src/common/llm/providers/greptile.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { SendLLMMessageParams } from '../types'
|
||||
|
||||
// Response type for Greptile API
|
||||
type GreptileResponse = {
|
||||
type: 'message' | 'sources' | 'status'
|
||||
message: string | {
|
||||
filepath: string
|
||||
linestart: number | null
|
||||
lineend: number | null
|
||||
} | ''
|
||||
}
|
||||
|
||||
// Sends a message to Greptile API and handles the streaming response
|
||||
export const sendGreptileMsg = ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig,
|
||||
abortRef
|
||||
}: SendLLMMessageParams) => {
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
// Set up abort handler
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
// Make API request to Greptile
|
||||
fetch('https://api.greptile.com/v2/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Authorization": `Bearer ${voidConfig.greptile.apikey}`,
|
||||
"X-Github-Token": `${voidConfig.greptile.githubPAT}`,
|
||||
"Content-Type": `application/json`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
stream: true,
|
||||
repositories: [voidConfig.greptile.repoinfo],
|
||||
}),
|
||||
})
|
||||
.then(async response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
// Parse the streaming response into JSON array
|
||||
const text = await response.text()
|
||||
return JSON.parse(`[${text.trim().split('\n').join(',')}]`) as GreptileResponse[]
|
||||
})
|
||||
.then(async responseArr => {
|
||||
if (didAbort) return
|
||||
|
||||
// Process each response chunk
|
||||
for (const response of responseArr) {
|
||||
if (didAbort) break
|
||||
|
||||
switch (response.type) {
|
||||
case 'message':
|
||||
// Handle message chunks
|
||||
fullText += response.message as string
|
||||
onText(response.message as string, fullText)
|
||||
break
|
||||
|
||||
case 'sources': {
|
||||
// Handle source reference chunks
|
||||
const sourceInfo = response.message as {
|
||||
filepath: string
|
||||
linestart: number | null
|
||||
lineend: number | null
|
||||
}
|
||||
const sourceText = `\nSource: ${sourceInfo.filepath}${sourceInfo.linestart
|
||||
? ` (lines ${sourceInfo.linestart}-${sourceInfo.lineend})`
|
||||
: ''
|
||||
}\n`
|
||||
fullText += sourceText
|
||||
onText(sourceText, fullText)
|
||||
break
|
||||
}
|
||||
|
||||
case 'status':
|
||||
// Handle completion status
|
||||
if (!response.message) {
|
||||
onFinalMessage(fullText)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Handle any errors that occur during the request
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'An unknown error occurred'
|
||||
onError(errorMessage)
|
||||
})
|
||||
}
|
||||
113
extensions/void/src/common/llm/providers/ollama.ts
Normal file
113
extensions/void/src/common/llm/providers/ollama.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Ollama } from 'ollama/browser'
|
||||
import { SendLLMMessageParams } from '../types'
|
||||
import { parseMaxTokensStr } from '../utils'
|
||||
|
||||
/**
|
||||
* Check if an Ollama model is installed
|
||||
*/
|
||||
async function checkModelExists(ollama: Ollama, modelName: string): Promise<{
|
||||
exists: boolean,
|
||||
installedModels: string[]
|
||||
}> {
|
||||
const models = await ollama.list()
|
||||
const installedModels = models.models.map(m => m.name.replace(/:latest$/, ''))
|
||||
const exists = installedModels.some(m => m.startsWith(modelName))
|
||||
return { exists, installedModels }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build error message for when model is not found
|
||||
*/
|
||||
function buildModelNotFoundError(modelName: string, installedModels: string[]): string {
|
||||
return [
|
||||
`The model "${modelName}" is not available locally.`,
|
||||
`Please run 'ollama pull ${modelName}' to download it first`,
|
||||
`or try selecting one from the installed models:`,
|
||||
installedModels.join(', ')
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of Ollama chat functionality
|
||||
*/
|
||||
export const sendOllamaMsg = async ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig,
|
||||
abortRef
|
||||
}: SendLLMMessageParams) => {
|
||||
let didAbort = false
|
||||
let fullText = ""
|
||||
|
||||
// Set up abort handler
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Ollama client
|
||||
const ollama = new Ollama({
|
||||
host: voidConfig.ollama.endpoint
|
||||
})
|
||||
|
||||
// Check if model exists
|
||||
const { exists, installedModels } = await checkModelExists(
|
||||
ollama,
|
||||
voidConfig.ollama.model
|
||||
)
|
||||
|
||||
if (!exists) {
|
||||
const errorMessage = buildModelNotFoundError(
|
||||
voidConfig.ollama.model,
|
||||
installedModels
|
||||
)
|
||||
onText(errorMessage, errorMessage)
|
||||
onFinalMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Start streaming chat response
|
||||
const stream = await ollama.chat({
|
||||
model: voidConfig.ollama.model,
|
||||
messages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_predict: parseMaxTokensStr(voidConfig.default.maxTokens)
|
||||
}
|
||||
})
|
||||
|
||||
// Update abort handler
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
for await (const chunk of stream) {
|
||||
if (didAbort) return
|
||||
|
||||
const newText = chunk.message.content
|
||||
fullText += newText
|
||||
onText(newText, fullText)
|
||||
}
|
||||
|
||||
// Send final message
|
||||
onFinalMessage(fullText)
|
||||
|
||||
} catch (error) {
|
||||
// Handle connection errors
|
||||
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||
const errorMessage = [
|
||||
'Ollama service is not running.',
|
||||
'Please start the Ollama service and try again.'
|
||||
].join(' ')
|
||||
onText(errorMessage, errorMessage)
|
||||
onFinalMessage(errorMessage)
|
||||
}
|
||||
// Handle other errors
|
||||
else if (error) {
|
||||
onError(error.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
103
extensions/void/src/common/llm/providers/openai.ts
Normal file
103
extensions/void/src/common/llm/providers/openai.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import OpenAI from 'openai'
|
||||
import { SendLLMMessageParams } from '../types'
|
||||
import { parseMaxTokensStr } from '../utils'
|
||||
|
||||
export const sendOpenAIMsg = ({
|
||||
messages,
|
||||
onText,
|
||||
onFinalMessage,
|
||||
onError,
|
||||
voidConfig,
|
||||
abortRef
|
||||
}: SendLLMMessageParams) => {
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
let openai: OpenAI
|
||||
let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
|
||||
|
||||
const maxTokens = parseMaxTokensStr(voidConfig.default.maxTokens)
|
||||
|
||||
// Configure OpenAI client based on API type
|
||||
switch (voidConfig.default.whichApi) {
|
||||
case 'openAI':
|
||||
openai = new OpenAI({
|
||||
apiKey: voidConfig.openAI.apikey,
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
options = {
|
||||
model: voidConfig.openAI.model,
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: maxTokens
|
||||
}
|
||||
break
|
||||
|
||||
case 'openRouter':
|
||||
openai = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
apiKey: voidConfig.openRouter.apikey,
|
||||
dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": 'https://voideditor.com',
|
||||
"X-Title": 'Void Editor',
|
||||
}
|
||||
})
|
||||
options = {
|
||||
model: voidConfig.openRouter.model,
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: maxTokens
|
||||
}
|
||||
break
|
||||
|
||||
case 'openAICompatible':
|
||||
openai = new OpenAI({
|
||||
baseURL: voidConfig.openAICompatible.endpoint,
|
||||
apiKey: voidConfig.openAICompatible.apikey,
|
||||
dangerouslyAllowBrowser: true
|
||||
})
|
||||
options = {
|
||||
model: voidConfig.openAICompatible.model,
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: maxTokens
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid whichApi: ${voidConfig.default.whichApi}`)
|
||||
}
|
||||
|
||||
openai.chat.completions
|
||||
.create(options)
|
||||
.then(async response => {
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
for await (const chunk of response) {
|
||||
if (didAbort) return
|
||||
const newText = chunk.choices[0]?.delta?.content || ''
|
||||
fullText += newText
|
||||
onText(newText, fullText)
|
||||
}
|
||||
|
||||
onFinalMessage(fullText)
|
||||
})
|
||||
.catch(error => {
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
if (error.status === 401) {
|
||||
onError('Invalid API key.')
|
||||
} else {
|
||||
onError(`${error.name}:\n${error.message}`)
|
||||
}
|
||||
} else {
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
26
extensions/void/src/common/llm/types.ts
Normal file
26
extensions/void/src/common/llm/types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { VoidConfig } from '../../webviews/common/contextForConfig'
|
||||
|
||||
export type AbortRef = { current: (() => void) | null }
|
||||
|
||||
export type OnText = (newText: string, fullText: string) => void
|
||||
|
||||
export type OnFinalMessage = (input: string) => void
|
||||
|
||||
export type LLMMessageAnthropic = {
|
||||
role: 'user' | 'assistant',
|
||||
content: string,
|
||||
}
|
||||
|
||||
export type LLMMessage = {
|
||||
role: 'system' | 'user' | 'assistant',
|
||||
content: string,
|
||||
}
|
||||
|
||||
export type SendLLMMessageParams = {
|
||||
messages: LLMMessage[],
|
||||
onText: OnText,
|
||||
onFinalMessage: OnFinalMessage,
|
||||
onError: (error: string) => void,
|
||||
voidConfig: VoidConfig,
|
||||
abortRef: AbortRef,
|
||||
}
|
||||
6
extensions/void/src/common/llm/utils.ts
Normal file
6
extensions/void/src/common/llm/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export const parseMaxTokensStr = (maxTokensStr: string) => {
|
||||
let int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr)
|
||||
if (Number.isNaN(int))
|
||||
return undefined
|
||||
return int
|
||||
}
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import OpenAI from 'openai';
|
||||
import { Ollama } from 'ollama/browser'
|
||||
import { Content, GoogleGenerativeAI, GoogleGenerativeAIError, GoogleGenerativeAIFetchError } from '@google/generative-ai';
|
||||
import { VoidConfig } from '../webviews/common/contextForConfig'
|
||||
|
||||
export type AbortRef = { current: (() => void) | null }
|
||||
|
||||
export type OnText = (newText: string, fullText: string) => void
|
||||
|
||||
export type OnFinalMessage = (input: string) => void
|
||||
|
||||
export type LLMMessageAnthropic = {
|
||||
role: 'user' | 'assistant',
|
||||
content: string,
|
||||
}
|
||||
|
||||
export type LLMMessage = {
|
||||
role: 'system' | 'user' | 'assistant',
|
||||
content: string,
|
||||
}
|
||||
|
||||
type SendLLMMessageFnTypeInternal = (params: {
|
||||
messages: LLMMessage[],
|
||||
onText: OnText,
|
||||
onFinalMessage: OnFinalMessage,
|
||||
onError: (error: string) => void,
|
||||
voidConfig: VoidConfig,
|
||||
abortRef: AbortRef,
|
||||
}) => void
|
||||
|
||||
type SendLLMMessageFnTypeExternal = (params: {
|
||||
messages: LLMMessage[],
|
||||
onText: OnText,
|
||||
onFinalMessage: (fullText: string) => void,
|
||||
onError: (error: string) => void,
|
||||
voidConfig: VoidConfig | null,
|
||||
abortRef: AbortRef,
|
||||
}) => void
|
||||
|
||||
const parseMaxTokensStr = (maxTokensStr: string) => {
|
||||
// parse the string but only if the full string is a valid number, eg parseInt('100abc') should return NaN
|
||||
let int = isNaN(Number(maxTokensStr)) ? undefined : parseInt(maxTokensStr)
|
||||
if (Number.isNaN(int))
|
||||
return undefined
|
||||
return int
|
||||
}
|
||||
|
||||
// Anthropic
|
||||
const sendAnthropicMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig }) => {
|
||||
|
||||
const anthropic = new Anthropic({ apiKey: voidConfig.anthropic.apikey, dangerouslyAllowBrowser: true }); // defaults to process.env["ANTHROPIC_API_KEY"]
|
||||
|
||||
// find system messages and concatenate them
|
||||
const systemMessage = messages
|
||||
.filter(msg => msg.role === 'system')
|
||||
.map(msg => msg.content)
|
||||
.join('\n');
|
||||
|
||||
// remove system messages for Anthropic
|
||||
const anthropicMessages = messages.filter(msg => msg.role !== 'system') as LLMMessageAnthropic[]
|
||||
|
||||
const stream = anthropic.messages.stream({
|
||||
system: systemMessage,
|
||||
messages: anthropicMessages,
|
||||
model: voidConfig.anthropic.model,
|
||||
max_tokens: parseMaxTokensStr(voidConfig.default.maxTokens)!, // this might be undefined, but it will just throw an error for the user
|
||||
});
|
||||
|
||||
let did_abort = false
|
||||
|
||||
// when receive text
|
||||
stream.on('text', (newText, fullText) => {
|
||||
if (did_abort) return
|
||||
onText(newText, fullText)
|
||||
})
|
||||
|
||||
// when we get the final message on this stream (or when error/fail)
|
||||
stream.on('finalMessage', (claude_response) => {
|
||||
if (did_abort) return
|
||||
// stringify the response's content
|
||||
let content = claude_response.content.map(c => { if (c.type === 'text') { return c.text } }).join('\n');
|
||||
onFinalMessage(content)
|
||||
})
|
||||
|
||||
stream.on('error', (error) => {
|
||||
// the most common error will be invalid API key (401), so we handle this with a nice message
|
||||
if (error instanceof Anthropic.APIError && error.status === 401) {
|
||||
onError('Invalid API key.')
|
||||
}
|
||||
else {
|
||||
onError(error.message)
|
||||
}
|
||||
})
|
||||
|
||||
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
|
||||
const abort = () => {
|
||||
did_abort = true
|
||||
stream.controller.abort() // TODO need to test this to make sure it works, it might throw an error
|
||||
}
|
||||
|
||||
return { abort }
|
||||
};
|
||||
|
||||
// Gemini
|
||||
const sendGeminiMsg: SendLLMMessageFnTypeInternal = async ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
|
||||
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
const genAI = new GoogleGenerativeAI(voidConfig.gemini.apikey);
|
||||
const model = genAI.getGenerativeModel({ model: voidConfig.gemini.model });
|
||||
|
||||
// remove system messages that get sent to Gemini
|
||||
// str of all system messages
|
||||
let systemMessage = messages
|
||||
.filter(msg => msg.role === 'system')
|
||||
.map(msg => msg.content)
|
||||
.join('\n');
|
||||
|
||||
// Convert messages to Gemini format
|
||||
const geminiMessages: Content[] = messages
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.map((msg, i) => ({
|
||||
parts: [{ text: msg.content }],
|
||||
role: msg.role === 'assistant' ? 'model' : 'user'
|
||||
}))
|
||||
|
||||
model.generateContentStream({ contents: geminiMessages, systemInstruction: systemMessage, })
|
||||
.then(async response => {
|
||||
abortRef.current = () => {
|
||||
// response.stream.return(fullText)
|
||||
didAbort = true;
|
||||
}
|
||||
for await (const chunk of response.stream) {
|
||||
if (didAbort) return;
|
||||
const newText = chunk.text();
|
||||
fullText += newText;
|
||||
onText(newText, fullText);
|
||||
}
|
||||
onFinalMessage(fullText);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof GoogleGenerativeAIFetchError) {
|
||||
if (error.status === 400) {
|
||||
onError('Invalid API key.');
|
||||
}
|
||||
else {
|
||||
onError(`${error.name}:\n${error.message}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
onError(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI, OpenRouter, OpenAICompatible
|
||||
const sendOpenAIMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
|
||||
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
|
||||
abortRef.current = () => {
|
||||
didAbort = true;
|
||||
};
|
||||
|
||||
let openai: OpenAI
|
||||
let options: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
|
||||
|
||||
let maxTokens = parseMaxTokensStr(voidConfig.default.maxTokens)
|
||||
|
||||
if (voidConfig.default.whichApi === 'openAI') {
|
||||
openai = new OpenAI({ apiKey: voidConfig.openAI.apikey, dangerouslyAllowBrowser: true });
|
||||
options = { model: voidConfig.openAI.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
|
||||
}
|
||||
else if (voidConfig.default.whichApi === 'openRouter') {
|
||||
openai = new OpenAI({
|
||||
baseURL: "https://openrouter.ai/api/v1", apiKey: voidConfig.openRouter.apikey, dangerouslyAllowBrowser: true,
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": 'https://voideditor.com', // Optional, for including your app on openrouter.ai rankings.
|
||||
"X-Title": 'Void Editor', // Optional. Shows in rankings on openrouter.ai.
|
||||
},
|
||||
});
|
||||
options = { model: voidConfig.openRouter.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
|
||||
}
|
||||
else if (voidConfig.default.whichApi === 'openAICompatible') {
|
||||
openai = new OpenAI({ baseURL: voidConfig.openAICompatible.endpoint, apiKey: voidConfig.openAICompatible.apikey, dangerouslyAllowBrowser: true })
|
||||
options = { model: voidConfig.openAICompatible.model, messages: messages, stream: true, max_completion_tokens: maxTokens }
|
||||
}
|
||||
else {
|
||||
console.error(`sendOpenAIMsg: invalid whichApi: ${voidConfig.default.whichApi}`)
|
||||
throw new Error(`voidConfig.whichAPI was invalid: ${voidConfig.default.whichApi}`)
|
||||
}
|
||||
|
||||
openai.chat.completions
|
||||
.create(options)
|
||||
.then(async response => {
|
||||
abortRef.current = () => {
|
||||
// response.controller.abort()
|
||||
didAbort = true;
|
||||
}
|
||||
// when receive text
|
||||
for await (const chunk of response) {
|
||||
if (didAbort) return;
|
||||
const newText = chunk.choices[0]?.delta?.content || '';
|
||||
fullText += newText;
|
||||
onText(newText, fullText);
|
||||
}
|
||||
onFinalMessage(fullText);
|
||||
})
|
||||
// when error/fail - this catches errors of both .create() and .then(for await)
|
||||
.catch(error => {
|
||||
if (error instanceof OpenAI.APIError) {
|
||||
if (error.status === 401) {
|
||||
onError('Invalid API key.');
|
||||
}
|
||||
else {
|
||||
onError(`${error.name}:\n${error.message}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
onError(error);
|
||||
}
|
||||
})
|
||||
|
||||
};
|
||||
|
||||
// Ollama
|
||||
export const sendOllamaMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
|
||||
|
||||
let didAbort = false
|
||||
let fullText = ""
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true;
|
||||
};
|
||||
|
||||
const ollama = new Ollama({ host: voidConfig.ollama.endpoint })
|
||||
|
||||
// First check if model exists
|
||||
ollama.list()
|
||||
.then(async models => {
|
||||
const installedModels = models.models.map(m => m.name.replace(/:latest$/, ''))
|
||||
const modelExists = installedModels.some(m => m.startsWith(voidConfig.ollama.model));
|
||||
if (!modelExists) {
|
||||
const errorMessage = `The model "${voidConfig.ollama.model}" is not available locally. Please run 'ollama pull ${voidConfig.ollama.model}' to download it first or
|
||||
try selecting one from the Installed models: ${installedModels.join(', ')}`;
|
||||
onText(errorMessage, errorMessage);
|
||||
onFinalMessage(errorMessage);
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
return ollama.chat({
|
||||
model: voidConfig.ollama.model,
|
||||
messages: messages,
|
||||
stream: true,
|
||||
options: { num_predict: parseMaxTokensStr(voidConfig.default.maxTokens) }
|
||||
});
|
||||
})
|
||||
.then(async stream => {
|
||||
if (!stream) return;
|
||||
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
for await (const chunk of stream) {
|
||||
if (didAbort) return;
|
||||
const newText = chunk.message.content;
|
||||
fullText += newText;
|
||||
onText(newText, fullText);
|
||||
}
|
||||
onFinalMessage(fullText);
|
||||
})
|
||||
.catch(error => {
|
||||
// Check if the error is a connection error
|
||||
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||
const errorMessage = 'Ollama service is not running. Please start the Ollama service and try again.';
|
||||
onText(errorMessage, errorMessage);
|
||||
onFinalMessage(errorMessage);
|
||||
} else if (error) {
|
||||
onError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Greptile
|
||||
// https://docs.greptile.com/api-reference/query
|
||||
// https://docs.greptile.com/quickstart#sample-response-streamed
|
||||
|
||||
const sendGreptileMsg: SendLLMMessageFnTypeInternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
|
||||
|
||||
let didAbort = false
|
||||
let fullText = ''
|
||||
|
||||
// if abort is called, onFinalMessage is NOT called, and no later onTexts are called either
|
||||
abortRef.current = () => {
|
||||
didAbort = true
|
||||
}
|
||||
|
||||
fetch('https://api.greptile.com/v2/query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Authorization": `Bearer ${voidConfig.greptile.apikey}`,
|
||||
"X-Github-Token": `${voidConfig.greptile.githubPAT}`,
|
||||
"Content-Type": `application/json`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
stream: true,
|
||||
repositories: [voidConfig.greptile.repoinfo],
|
||||
}),
|
||||
})
|
||||
// this is {message}\n{message}\n{message}...\n
|
||||
.then(async response => {
|
||||
const text = await response.text()
|
||||
console.log('got greptile', text)
|
||||
return JSON.parse(`[${text.trim().split('\n').join(',')}]`)
|
||||
})
|
||||
// TODO make this actually stream, right now it just sends one message at the end
|
||||
.then(async responseArr => {
|
||||
if (didAbort)
|
||||
return
|
||||
|
||||
for (let response of responseArr) {
|
||||
|
||||
const type: string = response['type']
|
||||
const message = response['message']
|
||||
|
||||
// when receive text
|
||||
if (type === 'message') {
|
||||
fullText += message
|
||||
onText(message, fullText)
|
||||
}
|
||||
else if (type === 'sources') {
|
||||
const { filepath, linestart, lineend } = message as { filepath: string, linestart: number | null, lineend: number | null }
|
||||
fullText += filepath
|
||||
onText(filepath, fullText)
|
||||
}
|
||||
// type: 'status' with an empty 'message' means last message
|
||||
else if (type === 'status') {
|
||||
if (!message) {
|
||||
onFinalMessage(fullText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
.catch(e => {
|
||||
onError(e)
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export const sendLLMMessage: SendLLMMessageFnTypeExternal = ({ messages, onText, onFinalMessage, onError, voidConfig, abortRef }) => {
|
||||
if (!voidConfig) return;
|
||||
|
||||
// trim message content (Anthropic and other providers give an error if there is trailing whitespace)
|
||||
messages = messages.map(m => ({ ...m, content: m.content.trim() }))
|
||||
|
||||
switch (voidConfig.default.whichApi) {
|
||||
case 'anthropic':
|
||||
return sendAnthropicMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
|
||||
case 'openAI':
|
||||
case 'openRouter':
|
||||
case 'openAICompatible':
|
||||
return sendOpenAIMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
|
||||
case 'gemini':
|
||||
return sendGeminiMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
|
||||
case 'ollama':
|
||||
return sendOllamaMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
|
||||
case 'greptile':
|
||||
return sendGreptileMsg({ messages, onText, onFinalMessage, onError, voidConfig, abortRef });
|
||||
default:
|
||||
onError(`Error: whichApi was ${voidConfig.default.whichApi}, which is not recognized!`)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { AbortRef, sendLLMMessage } from '../common/sendLLMMessage';
|
||||
|
||||
import { sendLLMMessage } from '../common/llm';
|
||||
import { AbortRef } from '../common/llm/types';
|
||||
import { DiffArea } from '../common/shared_types';
|
||||
import { writeFileWithDiffInstructions, searchDiffChunkInstructions } from '../common/systemPrompts';
|
||||
import { VoidConfig } from '../webviews/common/contextForConfig';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as vscode from 'vscode';
|
||||
import { AbortRef, OnFinalMessage, OnText, sendLLMMessage } from "../common/sendLLMMessage"
|
||||
import { sendLLMMessage } from "../common/llm"
|
||||
import { AbortRef, OnFinalMessage, OnText } from '../common/llm/types'
|
||||
import { VoidConfig } from '../webviews/common/contextForConfig';
|
||||
import { searchDiffChunkInstructions, writeFileWithDiffInstructions } from '../common/systemPrompts';
|
||||
import { throttle } from 'lodash';
|
||||
|
|
@ -97,4 +98,4 @@ Complete the following:
|
|||
|
||||
|
||||
|
||||
export { applyCtrlK }
|
||||
export { applyCtrlK }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as vscode from 'vscode';
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { AbortRef } from '../common/sendLLMMessage';
|
||||
import { AbortRef } from '../common/llm/types';
|
||||
import { MessageToSidebar, MessageFromSidebar, DiffArea, ChatThreads } from '../common/shared_types';
|
||||
import { getVoidConfigFromPartial } from '../webviews/common/contextForConfig';
|
||||
import { applyDiffLazily } from './applyDiffLazily';
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { File, ChatMessage, CodeSelection } from "../../common/shared_types";
|
|||
import * as vscode from 'vscode'
|
||||
import { awaitVSCodeResponse, getVSCodeAPI, onMessageFromVSCode, useOnVSCodeMessage } from "../common/getVscodeApi";
|
||||
import { useThreads } from "../common/contextForThreads";
|
||||
import { sendLLMMessage } from "../../common/sendLLMMessage";
|
||||
import { sendLLMMessage } from "../../common/llm";
|
||||
import { useVoidConfig } from "../common/contextForConfig";
|
||||
import { captureEvent } from "../common/posthog";
|
||||
import { generateDiffInstructions } from "../../common/systemPrompts";
|
||||
|
|
|
|||
Loading…
Reference in a new issue