diff --git a/src/core/chorus/ModelProviders/simple/ISimpleCompletionProvider.ts b/src/core/chorus/ModelProviders/simple/ISimpleCompletionProvider.ts new file mode 100644 index 00000000..6786c97b --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/ISimpleCompletionProvider.ts @@ -0,0 +1,24 @@ +export enum SimpleCompletionMode { + TITLE_GENERATION = "title_generation", + SUMMARIZER = "summarizer", +} + +export type SimpleCompletionParams = { + model?: SimpleCompletionMode | string; + maxTokens: number; +}; + +/** + * Lightweight interface for simple LLM completions. + * Used for utility tasks like generating chat titles and suggestions. + * Intentionally separate from IProvider to avoid coupling to streaming/tools/attachments. + */ +export interface ISimpleCompletionProvider { + /** + * Performs a simple completion request. + * @param prompt The prompt to send to the model + * @param params Completion parameters including model and maxTokens + * @returns The full response text + */ + complete(prompt: string, params: SimpleCompletionParams): Promise; +} diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderAnthropic.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderAnthropic.ts new file mode 100644 index 00000000..6689691d --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderAnthropic.ts @@ -0,0 +1,61 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { + ISimpleCompletionProvider, + SimpleCompletionParams, + SimpleCompletionMode, +} from "./ISimpleCompletionProvider"; + +const DEFAULT_TITLE_MODEL = "claude-haiku-4-5"; +const DEFAULT_SUMMARIZER_MODEL = "claude-haiku-4-5"; + +export class SimpleCompletionProviderAnthropic + implements ISimpleCompletionProvider +{ + constructor(private apiKey: string) {} + + async complete( + prompt: string, + params: SimpleCompletionParams, + ): Promise { + const client = new Anthropic({ + apiKey: this.apiKey, + dangerouslyAllowBrowser: true, + }); + + const model = this.getModel(params.model); + + const stream = client.messages.stream({ + model, + max_tokens: params.maxTokens, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + let fullResponse = ""; + + stream.on("text", (text: string) => { + fullResponse += text; + }); + + await stream.finalMessage(); + + return fullResponse; + } + + private getModel(model: SimpleCompletionMode | string | undefined): string { + if (model === SimpleCompletionMode.SUMMARIZER) { + return DEFAULT_SUMMARIZER_MODEL; + } + if (model === SimpleCompletionMode.TITLE_GENERATION) { + return DEFAULT_TITLE_MODEL; + } + if (typeof model === "string") { + return model; + } + return DEFAULT_TITLE_MODEL; + } +} diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts new file mode 100644 index 00000000..d81ffc47 --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderFactory.ts @@ -0,0 +1,67 @@ +import { ApiKeys } from "../../Models"; +import { canProceedWithProvider } from "@core/utilities/ProxyUtils"; +import { ISimpleCompletionProvider } from "./ISimpleCompletionProvider"; +import { SimpleCompletionProviderAnthropic } from "./SimpleCompletionProviderAnthropic"; +import { SimpleCompletionProviderOpenRouter } from "./SimpleCompletionProviderOpenRouter"; +import { SimpleCompletionProviderOpenAI } from "./SimpleCompletionProviderOpenAI"; +import { SimpleCompletionProviderGoogle } from "./SimpleCompletionProviderGoogle"; + +type ProviderConfig = { + name: string; + key: keyof ApiKeys; + create: (apiKey: string) => ISimpleCompletionProvider; +}; + +const PROVIDER_PRECEDENCE: ProviderConfig[] = [ + { + name: "anthropic", + key: "anthropic", + create: (key) => new SimpleCompletionProviderAnthropic(key), + }, + { + name: "openai", + key: "openai", + create: (key) => new SimpleCompletionProviderOpenAI(key), + }, + { + name: "google", + key: "google", + create: (key) => new SimpleCompletionProviderGoogle(key), + }, + { + name: "openrouter", + key: "openrouter", + create: (key) => new SimpleCompletionProviderOpenRouter(key), + }, +]; + +/** + * Factory function that selects and returns an appropriate simple completion provider + * based on available API keys. Follows explicit precedence order. + * + * @param apiKeys The API keys object from settings + * @returns An ISimpleCompletionProvider instance + * @throws Error if no suitable provider is configured + */ +export function getSimpleCompletionProvider( + apiKeys: ApiKeys, +): ISimpleCompletionProvider { + const reasons: string[] = []; + + for (const provider of PROVIDER_PRECEDENCE) { + const check = canProceedWithProvider(provider.name, apiKeys); + const apiKey = apiKeys[provider.key]; + + if (check.canProceed && apiKey) { + return provider.create(apiKey); + } + + if (!check.canProceed && check.reason) { + reasons.push(check.reason); + } + } + + throw new Error( + `Please add an Anthropic, OpenAI, Google, or OpenRouter API key in Settings to generate chat titles. ${reasons.join(" ")}`, + ); +} diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderGoogle.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderGoogle.ts new file mode 100644 index 00000000..2884c72d --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderGoogle.ts @@ -0,0 +1,64 @@ +import OpenAI from "openai"; +import { + ISimpleCompletionProvider, + SimpleCompletionParams, + SimpleCompletionMode, +} from "./ISimpleCompletionProvider"; + +const DEFAULT_TITLE_MODEL = "gemini-2.5-flash"; +const DEFAULT_SUMMARIZER_MODEL = "gemini-2.5-flash"; + +export class SimpleCompletionProviderGoogle + implements ISimpleCompletionProvider +{ + constructor(private apiKey: string) {} + + async complete( + prompt: string, + params: SimpleCompletionParams, + ): Promise { + const client = new OpenAI({ + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + apiKey: this.apiKey, + dangerouslyAllowBrowser: true, + }); + + const model = this.getModel(params.model); + + const stream = await client.chat.completions.create({ + model, + max_tokens: params.maxTokens, + stream: true, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + let fullResponse = ""; + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") { + fullResponse += delta; + } + } + + return fullResponse; + } + + private getModel(model: SimpleCompletionMode | string | undefined): string { + if (model === SimpleCompletionMode.SUMMARIZER) { + return DEFAULT_SUMMARIZER_MODEL; + } + if (model === SimpleCompletionMode.TITLE_GENERATION) { + return DEFAULT_TITLE_MODEL; + } + if (typeof model === "string") { + return model; + } + return DEFAULT_TITLE_MODEL; + } +} diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenAI.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenAI.ts new file mode 100644 index 00000000..71f68702 --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenAI.ts @@ -0,0 +1,63 @@ +import OpenAI from "openai"; +import { + ISimpleCompletionProvider, + SimpleCompletionParams, + SimpleCompletionMode, +} from "./ISimpleCompletionProvider"; + +const DEFAULT_TITLE_MODEL = "gpt-5-mini"; +const DEFAULT_SUMMARIZER_MODEL = "gpt-5-mini"; + +export class SimpleCompletionProviderOpenAI + implements ISimpleCompletionProvider +{ + constructor(private apiKey: string) {} + + async complete( + prompt: string, + params: SimpleCompletionParams, + ): Promise { + const client = new OpenAI({ + apiKey: this.apiKey, + dangerouslyAllowBrowser: true, + }); + + const model = this.getModel(params.model); + + const stream = await client.chat.completions.create({ + model, + max_tokens: params.maxTokens, + stream: true, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + let fullResponse = ""; + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") { + fullResponse += delta; + } + } + + return fullResponse; + } + + private getModel(model: SimpleCompletionMode | string | undefined): string { + if (model === SimpleCompletionMode.SUMMARIZER) { + return DEFAULT_SUMMARIZER_MODEL; + } + if (model === SimpleCompletionMode.TITLE_GENERATION) { + return DEFAULT_TITLE_MODEL; + } + if (typeof model === "string") { + return model; + } + return DEFAULT_TITLE_MODEL; + } +} diff --git a/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenRouter.ts b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenRouter.ts new file mode 100644 index 00000000..b599b549 --- /dev/null +++ b/src/core/chorus/ModelProviders/simple/SimpleCompletionProviderOpenRouter.ts @@ -0,0 +1,68 @@ +import OpenAI from "openai"; +import { + ISimpleCompletionProvider, + SimpleCompletionParams, + SimpleCompletionMode, +} from "./ISimpleCompletionProvider"; + +const DEFAULT_TITLE_MODEL = "anthropic/claude-haiku-4.5"; +const DEFAULT_SUMMARIZER_MODEL = "anthropic/claude-haiku-4.5"; + +export class SimpleCompletionProviderOpenRouter + implements ISimpleCompletionProvider +{ + constructor(private apiKey: string) {} + + async complete( + prompt: string, + params: SimpleCompletionParams, + ): Promise { + const client = new OpenAI({ + baseURL: "https://openrouter.ai/api/v1", + apiKey: this.apiKey, + defaultHeaders: { + "HTTP-Referer": "https://chorus.sh", + "X-Title": "Chorus", + }, + dangerouslyAllowBrowser: true, + }); + + const model = this.getModel(params.model); + + const stream = await client.chat.completions.create({ + model, + max_tokens: params.maxTokens, + stream: true, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + let fullResponse = ""; + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content; + if (typeof delta === "string") { + fullResponse += delta; + } + } + + return fullResponse; + } + + private getModel(model: SimpleCompletionMode | string | undefined): string { + if (model === SimpleCompletionMode.SUMMARIZER) { + return DEFAULT_SUMMARIZER_MODEL; + } + if (model === SimpleCompletionMode.TITLE_GENERATION) { + return DEFAULT_TITLE_MODEL; + } + if (typeof model === "string") { + return model; + } + return DEFAULT_TITLE_MODEL; + } +} diff --git a/src/core/chorus/api/MessageAPI.ts b/src/core/chorus/api/MessageAPI.ts index 50b380f2..8e0505cb 100644 --- a/src/core/chorus/api/MessageAPI.ts +++ b/src/core/chorus/api/MessageAPI.ts @@ -21,7 +21,8 @@ import * as Models from "../Models"; import { UpdateQueue } from "../UpdateQueue"; import posthog from "posthog-js"; import { v4 as uuidv4 } from "uuid"; -import { simpleLLM, simpleSummarizeLLM } from "../simpleLLM"; +import { simpleLLM } from "../simpleLLM"; +import { SimpleCompletionMode } from "../ModelProviders/simple/ISimpleCompletionProvider"; import * as Prompts from "../prompts/prompts"; import { useNavigate } from "react-router-dom"; import { ToolsetsManager } from "../ToolsetsManager"; @@ -2164,9 +2165,8 @@ export function useSummarizeChat() { conversationText, ); - const summary = await simpleSummarizeLLM(prompt, { - // NOTE: If you change this model _provider_, you'll need to update the response handling in simpleSummarizeLLM.ts - model: "gemini-2.5-flash", + const summary = await simpleLLM(prompt, { + model: SimpleCompletionMode.SUMMARIZER, maxTokens: 8192, }); @@ -2925,7 +2925,6 @@ If there's no information in the message, just return "Untitled Chat". ${userMessageText} `, { - model: "claude-3-5-sonnet-latest", maxTokens: 100, }, ); diff --git a/src/core/chorus/api/ProjectAPI.ts b/src/core/chorus/api/ProjectAPI.ts index d6238b45..920b9a0e 100644 --- a/src/core/chorus/api/ProjectAPI.ts +++ b/src/core/chorus/api/ProjectAPI.ts @@ -6,7 +6,8 @@ import * as Prompts from "../prompts/prompts"; import { produce } from "immer"; import { useGetMessageSets } from "./MessageAPI"; import { llmConversation } from "../ChatState"; -import { simpleSummarizeLLM } from "../simpleLLM"; +import { simpleLLM } from "../simpleLLM"; +import { SimpleCompletionMode } from "../ModelProviders/simple/ISimpleCompletionProvider"; import _ from "lodash"; import { useNavigate } from "react-router-dom"; import { db } from "../DB"; @@ -401,11 +402,10 @@ function useRegenerateProjectContextSummary() { .map((m) => `${m.role}: ${m.content}`) .join("\n\n"); - const summary = await simpleSummarizeLLM( + const summary = await simpleLLM( Prompts.PROJECT_CONTEXT_SUMMARY_PROMPT(conversationText), { - // NOTE: If you change this model _provider_, you'll need to update the response handling in simpleSummarizeLLM.ts - model: "gemini-2.5-flash", + model: SimpleCompletionMode.SUMMARIZER, maxTokens: 8192, }, ); diff --git a/src/core/chorus/simpleLLM.ts b/src/core/chorus/simpleLLM.ts index c72961dc..c9591df4 100644 --- a/src/core/chorus/simpleLLM.ts +++ b/src/core/chorus/simpleLLM.ts @@ -1,132 +1,28 @@ -import Anthropic from "@anthropic-ai/sdk"; import { SettingsManager } from "@core/utilities/Settings"; - -type SimpleLLMParams = { - model: string; - maxTokens: number; -}; +import { getSimpleCompletionProvider } from "./ModelProviders/simple/SimpleCompletionProviderFactory"; +import { + SimpleCompletionParams, + SimpleCompletionMode, +} from "./ModelProviders/simple/ISimpleCompletionProvider"; /** - * Makes a simple LLM call using the Anthropic SDK directly. - * Used for generating chat titles. + * Makes a simple LLM call using the first available provider. + * Used primarily for generating chat titles and suggestions. */ export async function simpleLLM( prompt: string, - params: SimpleLLMParams, + params: SimpleCompletionParams, ): Promise { const settingsManager = SettingsManager.getInstance(); const settings = await settingsManager.get(); - const apiKey = settings.apiKeys?.anthropic; - - if (!apiKey) { - throw new Error("Please add your Anthropic API key in Settings."); - } - - const client = new Anthropic({ - apiKey, - dangerouslyAllowBrowser: true, - }); - - const stream = client.messages.stream({ - model: params.model, - max_tokens: params.maxTokens, - messages: [ - { - role: "user", - content: prompt, - }, - ], - }); - - let fullResponse = ""; - - stream.on("text", (text: string) => { - fullResponse += text; - }); - - await stream.finalMessage(); - - return fullResponse; -} - -/** - * Makes a simple LLM call using Google's Gemini models via OpenAI-compatible API. - * Used for generating chat titles and summaries. - */ -export async function simpleSummarizeLLM( - prompt: string, - params: SimpleLLMParams, -): Promise { - const settingsManager = SettingsManager.getInstance(); - const settings = await settingsManager.get(); - const apiKey = settings.apiKeys?.google; - - if (!apiKey) { - throw new Error("Please add your Google AI API key in Settings."); - } - - // Use Google's OpenAI-compatible endpoint - const response = await fetch( - "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - max_tokens: params.maxTokens, - stream: true, - messages: [ - { - role: "user", - content: prompt, - }, - ], - }), - }, - ); - - if (!response.ok) { - throw new Error(`Google API error: ${response.statusText}`); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No reader available"); - - const decoder = new TextDecoder(); - let fullResponse = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split("\n"); - - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const dataStr = line.slice(6); - if (dataStr === "[DONE]") continue; - - const data = JSON.parse(dataStr) as { - choices?: Array<{ - delta?: { content?: string }; - }>; - }; + const apiKeys = settings.apiKeys || {}; - const content = data.choices?.[0]?.delta?.content; - if (content) { - fullResponse += content; - } - } catch (e) { - console.warn("Error parsing chunk:", e); - } - } - } - } + // Default to title generation mode if no model specified + const paramsWithMode: SimpleCompletionParams = { + ...params, + model: params.model ?? SimpleCompletionMode.TITLE_GENERATION, + }; - return fullResponse; + const provider = getSimpleCompletionProvider(apiKeys); + return provider.complete(prompt, paramsWithMode); } diff --git a/src/ui/components/ChatSuggestions.tsx b/src/ui/components/ChatSuggestions.tsx index a9cdafd0..c85940a4 100644 --- a/src/ui/components/ChatSuggestions.tsx +++ b/src/ui/components/ChatSuggestions.tsx @@ -88,7 +88,6 @@ Generate exactly 2 suggestions as a JSON array of strings. Each suggestion shoul let response: string; try { response = await simpleLLM(prompt, { - model: "claude-3-5-sonnet-latest", maxTokens: 512, }); } catch (llmError) {