feat: add better provider support, icons, regen, and a lot more

This commit is contained in:
Zoe
2026-02-12 14:56:13 +00:00
parent d5a5945c03
commit d29f95bacf
124 changed files with 6374 additions and 1861 deletions
+108
View File
@@ -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,
};
}
+84
View File
@@ -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);
}
},
});
}
}
+6 -6
View File
@@ -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);
};
+116
View File
@@ -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 });
}
}
});
};
}
+26
View File
@@ -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];
};