diff --git a/src/core/chorus/ModelProviders/ProviderOpenPaths.ts b/src/core/chorus/ModelProviders/ProviderOpenPaths.ts new file mode 100644 index 00000000..e68deba6 --- /dev/null +++ b/src/core/chorus/ModelProviders/ProviderOpenPaths.ts @@ -0,0 +1,210 @@ +import OpenAI from "openai"; +import { StreamResponseParams } from "../Models"; +import { IProvider, ModelDisabled } from "./IProvider"; +import OpenAICompletionsAPIUtils from "@core/chorus/OpenAICompletionsAPIUtils"; +import { canProceedWithProvider } from "@core/utilities/ProxyUtils"; +import JSON5 from "json5"; + +interface ProviderError { + message: string; + error?: { + message?: string; + metadata?: { raw?: string }; + }; + metadata?: { raw?: string }; +} + +function isProviderError(error: unknown): error is ProviderError { + return ( + typeof error === "object" && + error !== null && + "message" in error && + ("error" in error || "metadata" in error) && + error.message === "Provider returned error" + ); +} + +// OpenPaths is an OpenAI-compatible gateway (https://openpaths.io). +// uses OpenAI provider to format the messages +export class ProviderOpenPaths implements IProvider { + async streamResponse({ + llmConversation, + modelConfig, + onChunk, + onComplete, + apiKeys, + additionalHeaders, + tools, + onError, + customBaseUrl, + }: StreamResponseParams): Promise { + const modelName = modelConfig.modelId.split("::")[1]; + // Use the model's supportedAttachmentTypes from the database instead of hardcoded list + // Add null safety check in case supportedAttachmentTypes is undefined or null + const supportsImages = + modelConfig.supportedAttachmentTypes?.includes("image") ?? false; + + const { canProceed, reason } = canProceedWithProvider( + "openpaths", + apiKeys, + ); + + if (!canProceed) { + throw new Error( + reason || "Please add your OpenPaths API key in Settings.", + ); + } + + const baseURL = customBaseUrl || "https://openpaths.io/v1"; + + const client = new OpenAI({ + baseURL, + apiKey: apiKeys.openpaths, + defaultHeaders: { + ...(additionalHeaders ?? {}), + "HTTP-Referer": "https://chorus.sh", + "X-Title": "Chorus", + }, + dangerouslyAllowBrowser: true, + }); + + let messages: OpenAI.ChatCompletionMessageParam[] = + await OpenAICompletionsAPIUtils.convertConversation( + llmConversation, + { + imageSupport: supportsImages, + functionSupport: true, + }, + ); + + if (modelConfig.systemPrompt) { + messages = [ + { + role: "system", + content: modelConfig.systemPrompt, + }, + ...messages, + ]; + } + + const params: OpenAI.ChatCompletionCreateParamsStreaming & { + include_reasoning: boolean; + } = { + model: modelName, + messages, + stream: true, + include_reasoning: true, + }; + + // Add tools definitions + if (tools && tools.length > 0) { + params.tools = + OpenAICompletionsAPIUtils.convertToolDefinitions(tools); + params.tool_choice = "auto"; + } + + const chunks: OpenAI.ChatCompletionChunk[] = []; + let generationId: string | undefined; + + try { + const stream = await client.chat.completions.create(params); + + for await (const chunk of stream) { + chunks.push(chunk); + // Capture the generation ID from the first chunk + if (!generationId && chunk.id) { + generationId = chunk.id; + } + if (chunk.choices[0]?.delta?.content) { + onChunk(chunk.choices[0].delta.content); + } + } + } catch (error: unknown) { + console.error( + "Raw error from ProviderOpenPaths:", + error, + modelName, + messages, + ); + console.error(JSON.stringify(error, null, 2)); + + if ( + isProviderError(error) && + error.message === "Provider returned error" + ) { + let errorDetails: ProviderError; + try { + errorDetails = JSON5.parse( + error.error?.metadata?.raw || + error.metadata?.raw || + "{}", + ); + } catch { + errorDetails = { + message: "Failed to parse error details", + error: { message: "Failed to parse error details" }, + }; + } + const errorMessage = `Provider returned error: ${errorDetails.error?.message || error.message}`; + if (onError) { + onError(errorMessage); + } else { + throw new Error(errorMessage); + } + } else { + if (onError) { + onError(getErrorMessage(error)); + } else { + throw error; + } + } + return undefined; + } + + // Extract usage data from the last chunk + const lastChunk = chunks[chunks.length - 1]; + let usageData: + | { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + generation_id?: string; + } + | undefined; + + if (lastChunk?.usage) { + usageData = { + prompt_tokens: lastChunk.usage.prompt_tokens, + completion_tokens: lastChunk.usage.completion_tokens, + total_tokens: lastChunk.usage.total_tokens, + generation_id: generationId, + }; + } else if (generationId) { + // Even if no usage data in chunks, pass the generation ID + usageData = { + generation_id: generationId, + }; + } + + const toolCalls = OpenAICompletionsAPIUtils.convertToolCalls( + chunks, + tools ?? [], + ); + + await onComplete( + undefined, + toolCalls.length > 0 ? toolCalls : undefined, + usageData, + ); + } +} + +function getErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + return (error as { message: string }).message; + } else if (typeof error === "string") { + return error; + } else { + return "Unknown error"; + } +} diff --git a/src/core/chorus/Models.ts b/src/core/chorus/Models.ts index 1563d354..601992e2 100644 --- a/src/core/chorus/Models.ts +++ b/src/core/chorus/Models.ts @@ -8,6 +8,7 @@ import { ProviderOpenAI } from "./ModelProviders/ProviderOpenAI"; import { ProviderAnthropic } from "./ModelProviders/ProviderAnthropic"; import { ProviderOpenRouter } from "./ModelProviders/ProviderOpenRouter"; +import { ProviderOpenPaths } from "./ModelProviders/ProviderOpenPaths"; import { ProviderPerplexity } from "./ModelProviders/ProviderPerplexity"; import { IProvider } from "./ModelProviders/IProvider"; import Database from "@tauri-apps/plugin-sql"; @@ -155,6 +156,7 @@ export type ApiKeys = { openai?: string; perplexity?: string; openrouter?: string; + openpaths?: string; google?: string; grok?: string; }; @@ -239,6 +241,7 @@ export type ProviderName = | "google" | "perplexity" | "openrouter" + | "openpaths" | "ollama" | "lmstudio" | "grok" @@ -288,6 +291,8 @@ function getProvider(providerName: string): IProvider { return new ProviderGoogle(); case "openrouter": return new ProviderOpenRouter(); + case "openpaths": + return new ProviderOpenPaths(); case "perplexity": return new ProviderPerplexity(); case "ollama": @@ -356,6 +361,7 @@ export async function DEPRECATED_USE_HOOK_INSTEAD_downloadModels( db: Database, ): Promise { await downloadOpenRouterModels(db); + await downloadOpenPathsModels(db); await downloadOllamaModels(db); await downloadLMStudioModels(db); return 0; @@ -432,6 +438,80 @@ export async function downloadOpenRouterModels(db: Database): Promise { return openRouterModels.length; } +/** + * Downloads models from OpenPaths to refresh the database. + * OpenPaths is an OpenAI-compatible gateway (https://openpaths.io). + */ +export async function downloadOpenPathsModels(db: Database): Promise { + const response = await fetch("https://openpaths.io/v1/models"); + if (!response.ok) { + console.error("Failed to fetch OpenPaths models"); + return 0; + } + const { data: openPathsModels } = (await response.json()) as { + data: { + id: string; + name?: string; + architecture?: { + input_modalities?: string[]; + }; + pricing?: { + prompt: string; + completion: string; + request?: string; + image?: string; + }; + }[]; + }; + + await db.execute( + "UPDATE models SET is_enabled = 0 WHERE id LIKE 'openpaths::%'", + ); + + await Promise.all( + openPathsModels.map((model) => { + // Check if the model supports images based on API metadata + // Use Array.isArray check to ensure input_modalities is an array before calling includes + const supportsImages = + Array.isArray(model.architecture?.input_modalities) && + model.architecture.input_modalities.includes("image"); + + // Parse and validate pricing data + const promptPrice = parseFloat(model.pricing?.prompt ?? ""); + const completionPrice = parseFloat(model.pricing?.completion ?? ""); + const hasPricing = + !isNaN(promptPrice) && + !isNaN(completionPrice) && + isFinite(promptPrice) && + isFinite(completionPrice); + + const displayName = model.name ?? model.id; + + return saveModelAndDefaultConfig( + db, + { + id: `openpaths::${model.id}`, + displayName: `${displayName}`, + supportedAttachmentTypes: supportsImages + ? ["text", "image", "webpage"] + : ["text", "webpage"], + isEnabled: true, + isInternal: false, + }, + `${displayName}`, + hasPricing + ? { + promptPricePerToken: promptPrice, + completionPricePerToken: completionPrice, + } + : undefined, + ); + }), + ); + + return openPathsModels.length; +} + /** * Downloads models from Ollama to refresh the database. */ @@ -609,6 +689,7 @@ const CONTEXT_LIMIT_PATTERNS: Record = { google: "token count", grok: "maximum prompt length", openrouter: "context length", + openpaths: "context length", meta: "context window", // best guess lmstudio: "context window", // best guess perplexity: "context window", // best guess diff --git a/src/core/chorus/api/ModelsAPI.ts b/src/core/chorus/api/ModelsAPI.ts index 2a30a09c..8c697ee1 100644 --- a/src/core/chorus/api/ModelsAPI.ts +++ b/src/core/chorus/api/ModelsAPI.ts @@ -75,6 +75,10 @@ type ModelConfigDBRow = { // the current session, and store the promise if a download is in progress. let openRouterDownloadPromise: Promise | null = null; +// Track whether we've attempted to refresh OpenPaths models within +// the current session, and store the promise if a download is in progress. +let openPathsDownloadPromise: Promise | null = null; + function readModel(row: ModelDBRow): Models.Model { return { id: row.id, @@ -125,6 +129,20 @@ export async function fetchModelConfigs() { } } + // Fetch OpenPaths models if we haven't already and the user has an OpenPaths API key. + if (apiKeys.openpaths) { + // If a download is already in progress, wait for it to complete. + // Otherwise, start a new download and store the promise. + if (openPathsDownloadPromise) { + await openPathsDownloadPromise; + } else { + openPathsDownloadPromise = Models.downloadOpenPathsModels(db); + await openPathsDownloadPromise; + // Keep the promise stored so subsequent calls know it completed + // (we don't clear it to prevent re-downloads within the session) + } + } + return ( await db.select( `SELECT model_configs.id, model_configs.display_name, model_configs.author, @@ -325,6 +343,21 @@ export function useRefreshOpenRouterModels() { }); } +export function useRefreshOpenPathsModels() { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: ["refreshOpenPathsModels"] as const, + mutationFn: async () => { + await Models.downloadOpenPathsModels(db); + }, + onSuccess: async () => { + await queryClient.invalidateQueries( + modelConfigQueries.listConfigs(), + ); + }, + }); +} + export function useRefreshOllamaModels() { const queryClient = useQueryClient(); return useMutation({ @@ -357,6 +390,7 @@ export function useRefreshLMStudioModels() { export function useRefreshModels() { const refreshOpenRouterModels = useRefreshOpenRouterModels(); + const refreshOpenPathsModels = useRefreshOpenPathsModels(); const refreshOllamaModels = useRefreshOllamaModels(); const refreshLMStudioModels = useRefreshLMStudioModels(); return useMutation({ @@ -364,6 +398,7 @@ export function useRefreshModels() { mutationFn: async () => { await Promise.all([ refreshOpenRouterModels.mutateAsync(), + refreshOpenPathsModels.mutateAsync(), refreshOllamaModels.mutateAsync(), refreshLMStudioModels.mutateAsync(), ]); diff --git a/src/core/utilities/ProxyUtils.ts b/src/core/utilities/ProxyUtils.ts index 7f3a48eb..60908569 100644 --- a/src/core/utilities/ProxyUtils.ts +++ b/src/core/utilities/ProxyUtils.ts @@ -14,6 +14,7 @@ const PROVIDER_TO_API_KEY: Record = { google: "google", perplexity: "perplexity", openrouter: "openrouter", + openpaths: "openpaths", grok: "grok", }; @@ -26,6 +27,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { google: "Google AI", perplexity: "Perplexity", openrouter: "OpenRouter", + openpaths: "OpenPaths", grok: "xAI", }; diff --git a/src/core/utilities/Settings.ts b/src/core/utilities/Settings.ts index f6114dcc..24b13541 100644 --- a/src/core/utilities/Settings.ts +++ b/src/core/utilities/Settings.ts @@ -14,6 +14,7 @@ export interface Settings { google?: string; perplexity?: string; openrouter?: string; + openpaths?: string; firecrawl?: string; }; quickChat?: { diff --git a/src/ui/components/ApiKeysForm.tsx b/src/ui/components/ApiKeysForm.tsx index 7e385462..1ae7d089 100644 --- a/src/ui/components/ApiKeysForm.tsx +++ b/src/ui/components/ApiKeysForm.tsx @@ -50,6 +50,12 @@ export default function ApiKeysForm({ placeholder: "sk-or-...", url: "https://openrouter.ai/keys", }, + { + id: "openpaths", + name: "OpenPaths", + placeholder: "op-...", + url: "https://openpaths.io/account", + }, { id: "grok", name: "xAI", diff --git a/src/ui/components/ui/provider-logo.tsx b/src/ui/components/ui/provider-logo.tsx index 49d1b342..5e9e9ca3 100644 --- a/src/ui/components/ui/provider-logo.tsx +++ b/src/ui/components/ui/provider-logo.tsx @@ -77,6 +77,9 @@ export function ProviderLogo({ className="w-4 h-4 invert dark:invert-0" /> ); + case "openpaths": + // TODO: Add OpenPaths logo + return ; default: { // @ts-expect-error: creating unused variable to provide exhaustiveness check const _unused: never = provider;