feat: add better provider support, icons, regen, and a lot more
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { createCerebras, type CerebrasProvider } from "@ai-sdk/cerebras";
|
||||
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google";
|
||||
import { createOpenRouter, type OpenRouterProvider } from "@openrouter/ai-sdk-provider";
|
||||
import { createOllama, type OllamaProvider } from "ai-sdk-ollama";
|
||||
import { createOpenAICompatible, type OpenAICompatibleProvider } from '@ai-sdk/openai-compatible';
|
||||
import { createCohere, type CohereProvider } from '@ai-sdk/cohere';
|
||||
import { type Entity } from "@triplit/client";
|
||||
import { schema } from "~~/triplit/schema";
|
||||
import { type StreamTextTransform } from "ai";
|
||||
import { transformCerebrasReasoningStream } from "./cerebras";
|
||||
import { providerBaseUrls } from "~/types/model";
|
||||
// import { createLongcatTransformer } from "./longcat";
|
||||
|
||||
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
|
||||
|
||||
export interface Gateway {
|
||||
gateway: ModelGateway;
|
||||
streamTransformer: StreamTextTransform<{}> | StreamTextTransform<{}>[] | undefined;
|
||||
textTransformer: ((text: string) => string) | ((text: string) => string)[] | undefined;
|
||||
}
|
||||
|
||||
export async function getGateway(provider: Entity<typeof schema, 'providers'>, model: Entity<typeof schema, 'models'>, providerApiKey?: string): Promise<Gateway> {
|
||||
let gateway: ModelGateway;
|
||||
let streamTransformer = undefined;
|
||||
let textTransformer = undefined;
|
||||
|
||||
let baseURL = undefined;
|
||||
|
||||
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
|
||||
baseURL = provider.config.apiProxyUrl;
|
||||
}
|
||||
|
||||
switch (provider.type) {
|
||||
case 'openrouter': {
|
||||
if (providerApiKey === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'OpenRouter provider requires an API key',
|
||||
});
|
||||
}
|
||||
|
||||
gateway = createOpenRouter({
|
||||
apiKey: providerApiKey,
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://localhost:3000',
|
||||
'X-Title': 'Veridian',
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'ollama': {
|
||||
if (baseURL === undefined) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'Ollama provider requires an API proxy URL',
|
||||
});
|
||||
}
|
||||
|
||||
const innerGateway = createOllama({
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
})
|
||||
|
||||
gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
|
||||
break;
|
||||
}
|
||||
case 'cerebras': {
|
||||
gateway = createCerebras({
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
})
|
||||
|
||||
streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
|
||||
textTransformer = (text: string) => {
|
||||
return text.split('</think>').at(-1)!.trim()
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'google': {
|
||||
gateway = createGoogleGenerativeAI({
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
})
|
||||
break;
|
||||
}
|
||||
case 'longcat': {
|
||||
gateway = createOpenAICompatible({
|
||||
name: 'LongCat',
|
||||
apiKey: providerApiKey,
|
||||
baseURL: baseURL ?? providerBaseUrls[provider.type],
|
||||
includeUsage: true,
|
||||
})
|
||||
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
|
||||
} break;
|
||||
case 'cohere': {
|
||||
gateway = createCohere({
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
gateway,
|
||||
streamTransformer,
|
||||
textTransformer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { TextStreamPart, ToolSet } from 'ai';
|
||||
|
||||
export function transformCerebrasReasoningStream<TOOLS extends ToolSet>(): (options: {
|
||||
tools: TOOLS;
|
||||
stopStream: () => void;
|
||||
}) => TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>> {
|
||||
|
||||
return (_opts) => {
|
||||
let isThinking = false;
|
||||
let currentReasoningId: string | null = null;
|
||||
|
||||
let bufferedTextStart: TextStreamPart<TOOLS> | null = null;
|
||||
let hasEmittedTextStart = false;
|
||||
|
||||
return new TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === 'text-start') {
|
||||
bufferedTextStart = chunk;
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.type === 'text-delta') {
|
||||
let text = chunk.text;
|
||||
|
||||
if (text.includes('<think>')) {
|
||||
isThinking = true;
|
||||
currentReasoningId = crypto.randomUUID();
|
||||
|
||||
const [before, after] = text.split('<think>');
|
||||
|
||||
if (before && before.trim().length > 0) {
|
||||
if (bufferedTextStart && !hasEmittedTextStart) {
|
||||
controller.enqueue(bufferedTextStart);
|
||||
hasEmittedTextStart = true;
|
||||
}
|
||||
controller.enqueue({ type: 'text-delta', text: before, id: chunk.id });
|
||||
}
|
||||
|
||||
controller.enqueue({ type: 'reasoning-start', id: currentReasoningId });
|
||||
|
||||
if (after) {
|
||||
controller.enqueue({ type: 'reasoning-delta', text: after, id: currentReasoningId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.includes('</think>')) {
|
||||
isThinking = false;
|
||||
const [before, after] = text.split('</think>');
|
||||
|
||||
if (before && currentReasoningId !== null) {
|
||||
controller.enqueue({ type: 'reasoning-delta', text: before, id: currentReasoningId });
|
||||
}
|
||||
|
||||
if (currentReasoningId !== null) {
|
||||
controller.enqueue({ type: 'reasoning-end', id: currentReasoningId });
|
||||
}
|
||||
|
||||
if (after && after.length > 0) {
|
||||
if (bufferedTextStart && !hasEmittedTextStart) {
|
||||
controller.enqueue(bufferedTextStart);
|
||||
hasEmittedTextStart = true;
|
||||
}
|
||||
controller.enqueue({ type: 'text-delta', text: after, id: chunk.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThinking && currentReasoningId !== null) {
|
||||
controller.enqueue({ type: 'reasoning-delta', text: text, id: currentReasoningId });
|
||||
} else {
|
||||
if (bufferedTextStart && !hasEmittedTextStart) {
|
||||
controller.enqueue(bufferedTextStart);
|
||||
hasEmittedTextStart = true;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
} else {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,22 @@
|
||||
const pendingGenerations: Record<string, AbortController> = {};
|
||||
const pendingGenerations: Map<string, AbortController> = new Map();
|
||||
|
||||
export const cancelPendingGeneration = (generationId: string): boolean => {
|
||||
const controller = pendingGenerations[generationId];
|
||||
const controller = pendingGenerations.get(generationId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
delete pendingGenerations[generationId];
|
||||
pendingGenerations.delete(generationId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const completeGeneration = (generationId: string) => {
|
||||
const controller = pendingGenerations[generationId];
|
||||
const controller = pendingGenerations.get(generationId);
|
||||
if (controller) {
|
||||
delete pendingGenerations[generationId];
|
||||
pendingGenerations.delete(generationId);
|
||||
}
|
||||
};
|
||||
|
||||
export const addPendingGeneration = (generationId: string, controller: AbortController) => {
|
||||
pendingGenerations[generationId] = controller;
|
||||
pendingGenerations.set(generationId, controller);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ToolSet, type TextStreamPart, type ToolCallPart } from 'ai';
|
||||
|
||||
export function createLongcatTransformer<TOOLS extends ToolSet>(): (options: {
|
||||
tools: TOOLS;
|
||||
stopStream: () => void;
|
||||
}) => TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>> {
|
||||
let buffer = '';
|
||||
let hasToolCallInStep = false;
|
||||
let lastChunkId: string | undefined;
|
||||
let lastChunkType: 'text' | 'reasoning' | undefined;
|
||||
let step = 0;
|
||||
|
||||
return (_opts) => {
|
||||
return new TransformStream<TextStreamPart<TOOLS>, TextStreamPart<TOOLS>>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === 'finish-step' || chunk.type === 'finish') {
|
||||
step++;
|
||||
|
||||
if (hasToolCallInStep) {
|
||||
// We clone the chunk and overwrite the finishReason.
|
||||
// This tricks the SDK into thinking the model requested a tool natively.
|
||||
const modifiedChunk = {
|
||||
...chunk,
|
||||
finishReason: 'tool-calls' as const,
|
||||
};
|
||||
|
||||
// Reset for the next potential step
|
||||
if (chunk.type === 'finish-step') {
|
||||
hasToolCallInStep = false;
|
||||
}
|
||||
|
||||
controller.enqueue(modifiedChunk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.type === 'text-start' || chunk.type === 'reasoning-start') {
|
||||
lastChunkId = chunk.id;
|
||||
lastChunkType = chunk.type.split('-')[1] as 'text' | 'reasoning';
|
||||
}
|
||||
|
||||
// We only care about text chunks
|
||||
if (chunk.type !== 'text-delta' && chunk.type !== 'reasoning-delta') {
|
||||
controller.enqueue(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += chunk.text;
|
||||
|
||||
// Check if we have a full tool call in the buffer
|
||||
const pattern = /<longcat_tool_call>([\s\S]*?)<\/longcat_tool_call>/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = pattern.exec(buffer)) !== null) {
|
||||
console.log("longcat tool call found at index", match.index);
|
||||
|
||||
// 1. Enqueue any text that appeared BEFORE the tool call
|
||||
const textBefore = buffer.substring(lastIndex, match.index);
|
||||
if (textBefore) {
|
||||
controller.enqueue({ type: chunk.type, text: textBefore, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
|
||||
}
|
||||
|
||||
// 2. Parse the XML content
|
||||
const content = match[1]!.trim();
|
||||
const toolNameMatch = content.match(/^([^\s<]+)/);
|
||||
|
||||
if (toolNameMatch) {
|
||||
hasToolCallInStep = true;
|
||||
|
||||
const toolName = toolNameMatch[1];
|
||||
const args: Record<string, any> = {};
|
||||
const argRegex = /<longcat_arg_key>(.*?)<\/longcat_arg_key>\s*<longcat_arg_value>(.*?)<\/longcat_arg_value>/gs;
|
||||
|
||||
let argMatch;
|
||||
while ((argMatch = argRegex.exec(content)) !== null) {
|
||||
args[argMatch[1]!.trim()] = argMatch[2]!.trim();
|
||||
}
|
||||
|
||||
// 3. EMIT A TOOL CALL PART
|
||||
// This is the "magic" - the SDK will see this and act as if the LLM
|
||||
// called a native tool.
|
||||
const toolCallId = `lc-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
|
||||
controller.enqueue({
|
||||
type: 'tool-call',
|
||||
// @ts-ignore
|
||||
id: toolCallId,
|
||||
toolCallId,
|
||||
toolName,
|
||||
input: args,
|
||||
dynamic: true,
|
||||
});
|
||||
}
|
||||
|
||||
lastIndex = pattern.lastIndex;
|
||||
}
|
||||
|
||||
// Keep the remaining buffer (unclosed tags) for the next chunk
|
||||
buffer = buffer.substring(lastIndex);
|
||||
|
||||
// If there's no open tag starting, we can flush the buffer as text
|
||||
if (!buffer.includes('<longcat_tool_call>')) {
|
||||
if (buffer) {
|
||||
controller.enqueue({ type: chunk.type, text: buffer, id: lastChunkId ?? chunk.type.includes('reasoning') ? `reasoning-${step}` : `text-${step}` });
|
||||
buffer = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
if (buffer && lastChunkId && lastChunkType) {
|
||||
controller.enqueue({ type: `${lastChunkType}-delta`, text: buffer, id: lastChunkId });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const pendingRenames: Map<string, AbortController> = new Map();
|
||||
|
||||
export const cancelPendingRename = (renameId: string): boolean => {
|
||||
const controller = pendingRenames.get(renameId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
pendingRenames.delete(renameId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const completeRename = (renameId: string) => {
|
||||
const controller = pendingRenames.get(renameId);
|
||||
if (controller) {
|
||||
pendingRenames.delete(renameId);
|
||||
}
|
||||
};
|
||||
|
||||
export const addPendingRename = (): [string, AbortController] => {
|
||||
const id = crypto.randomUUID();
|
||||
const controller = new AbortController();
|
||||
|
||||
pendingRenames.set(id, controller);
|
||||
return [id, controller];
|
||||
};
|
||||
Reference in New Issue
Block a user