Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions src/core/chorus/ModelProviders/ProviderOpenPaths.ts
Original file line number Diff line number Diff line change
@@ -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<ModelDisabled | void> {
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";
}
}
81 changes: 81 additions & 0 deletions src/core/chorus/Models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -155,6 +156,7 @@ export type ApiKeys = {
openai?: string;
perplexity?: string;
openrouter?: string;
openpaths?: string;
google?: string;
grok?: string;
};
Expand Down Expand Up @@ -239,6 +241,7 @@ export type ProviderName =
| "google"
| "perplexity"
| "openrouter"
| "openpaths"
| "ollama"
| "lmstudio"
| "grok"
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -356,6 +361,7 @@ export async function DEPRECATED_USE_HOOK_INSTEAD_downloadModels(
db: Database,
): Promise<number> {
await downloadOpenRouterModels(db);
await downloadOpenPathsModels(db);
await downloadOllamaModels(db);
await downloadLMStudioModels(db);
return 0;
Expand Down Expand Up @@ -432,6 +438,80 @@ export async function downloadOpenRouterModels(db: Database): Promise<number> {
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<number> {
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.
*/
Expand Down Expand Up @@ -609,6 +689,7 @@ const CONTEXT_LIMIT_PATTERNS: Record<ProviderName, string> = {
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
Expand Down
Loading