feat: ditch triplit, move to postgresql + drizzle orm
This commit is contained in:
+70
-18
@@ -4,15 +4,23 @@ import { createOpenRouter, type OpenRouterProvider } from "@openrouter/ai-sdk-pr
|
||||
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 { createMistral, type MistralProvider } from '@ai-sdk/mistral';
|
||||
import { createLongcat, type LongcatProvider } from 'longcat-ai-sdk-provider';
|
||||
import { type StreamTextTransform } from "ai";
|
||||
import { transformCerebrasReasoningStream } from "./cerebras";
|
||||
import { providerBaseUrls } from "~/types/model";
|
||||
import { providerBaseUrls, Providers } from "~/types/model";
|
||||
import type { Provider as ProviderDrizzle, Model as ModelDrizzle } from '~/composables/useModels';
|
||||
import { type Result, Err, Ok } from "~~/types/result";
|
||||
// import { createLongcatTransformer } from "./longcat";
|
||||
|
||||
export type ModelGateway = OpenRouterProvider | OllamaProvider | CerebrasProvider | GoogleGenerativeAIProvider | OpenAICompatibleProvider | CohereProvider;
|
||||
export type ModelGateway =
|
||||
OpenRouterProvider
|
||||
| OllamaProvider
|
||||
| CerebrasProvider
|
||||
| GoogleGenerativeAIProvider
|
||||
| OpenAICompatibleProvider
|
||||
| CohereProvider
|
||||
| MistralProvider
|
||||
| LongcatProvider;
|
||||
|
||||
export interface Gateway {
|
||||
gateway: ModelGateway;
|
||||
@@ -34,7 +42,7 @@ export enum GatewayFetchError {
|
||||
NoProviderBaseUrl,
|
||||
}
|
||||
|
||||
export async function getProviderDetails(provider: Entity<typeof schema, 'providers'>, providerApiKey?: string, model?: Entity<typeof schema, 'models'>): Promise<Result<Provider, GatewayFetchError>> {
|
||||
export async function getProviderDetails(provider: ProviderDrizzle, providerApiKey?: string, model?: ModelDrizzle): Promise<Result<Provider, GatewayFetchError>> {
|
||||
let gateway: Gateway = {} as Gateway;
|
||||
|
||||
let baseURL = undefined;
|
||||
@@ -44,11 +52,11 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
if (provider.config.apiProxyUrl && provider.config.apiProxyUrl.trim() !== '') {
|
||||
baseURL = provider.config.apiProxyUrl;
|
||||
} else {
|
||||
baseURL = providerBaseUrls[provider.type];
|
||||
baseURL = providerBaseUrls[provider.type as typeof Providers[number]];
|
||||
}
|
||||
baseURL = baseURL.replace(/\/$/, '');
|
||||
|
||||
switch (provider.type) {
|
||||
switch (provider.type as typeof Providers[number]) {
|
||||
case 'openrouter': {
|
||||
if (providerApiKey === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
@@ -64,6 +72,20 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = `/models`;
|
||||
} break;
|
||||
case 'closedrouter': {
|
||||
if (providerApiKey === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
}
|
||||
|
||||
gateway.gateway = createOpenAICompatible({
|
||||
name: 'ClosedRouter',
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
includeUsage: true,
|
||||
});
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = `/models`;
|
||||
} break;
|
||||
case 'ollama': {
|
||||
if (baseURL === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderBaseUrl);
|
||||
@@ -80,9 +102,19 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
baseURL,
|
||||
})
|
||||
|
||||
gateway.gateway = ((modelId: string) => innerGateway(modelId, { think: [...model.attributes.capabilities].includes('reasoning') })) as OllamaProvider;
|
||||
gateway.gateway = ((modelId: string) => innerGateway(modelId, { think: model.capabilities.includes('reasoning') })) as OllamaProvider;
|
||||
}
|
||||
} break;
|
||||
case 'vllm': {
|
||||
gateway.gateway = createOpenAICompatible({
|
||||
name: 'vLLM',
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
includeUsage: true,
|
||||
});
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = '/models';
|
||||
} break;
|
||||
case 'cerebras': {
|
||||
if (providerApiKey === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
@@ -93,10 +125,6 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
baseURL,
|
||||
})
|
||||
|
||||
gateway.streamTransformer = transformCerebrasReasoningStream() as StreamTextTransform<{}>;
|
||||
gateway.textTransformer = (text: string) => {
|
||||
return text.split('</think>').at(-1)!.trim()
|
||||
};
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = `/models`;
|
||||
} break;
|
||||
@@ -117,12 +145,10 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
}
|
||||
|
||||
gateway.gateway = createOpenAICompatible({
|
||||
name: 'LongCat',
|
||||
gateway.gateway = createLongcat({
|
||||
apiKey: providerApiKey,
|
||||
baseURL: baseURL ?? providerBaseUrls[provider.type],
|
||||
includeUsage: true,
|
||||
})
|
||||
baseURL,
|
||||
});
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = null;
|
||||
// streamTransformer = createLongcatTransformer() as StreamTextTransform<{}>;
|
||||
@@ -139,6 +165,32 @@ export async function getProviderDetails(provider: Entity<typeof schema, 'provid
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = `/models`;
|
||||
} break;
|
||||
case 'inception': {
|
||||
if (providerApiKey === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
}
|
||||
|
||||
gateway.gateway = createOpenAICompatible({
|
||||
name: 'Inception',
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
includeUsage: true,
|
||||
});
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = null;
|
||||
}
|
||||
case 'mistral': {
|
||||
if (providerApiKey === undefined) {
|
||||
return Err(GatewayFetchError.NoProviderApiKey);
|
||||
}
|
||||
|
||||
gateway.gateway = createMistral({
|
||||
apiKey: providerApiKey,
|
||||
baseURL,
|
||||
});
|
||||
headers['Authorization'] = `Bearer ${providerApiKey}`
|
||||
modelsEndpoint = '/models';
|
||||
}
|
||||
}
|
||||
|
||||
return Ok({
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
interface StreamConnection {
|
||||
controller: ReadableStreamDefaultController;
|
||||
lastPing: number;
|
||||
subscribedAt: number;
|
||||
}
|
||||
|
||||
const userChannels = new Map<string, Set<StreamConnection>>();
|
||||
|
||||
const PING_INTERVAL = 15_000;
|
||||
|
||||
function sendPing(conn: StreamConnection, onError: (e: any) => void) {
|
||||
try {
|
||||
conn.controller.enqueue(':ping\n\n');
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export const userEvents = {
|
||||
subscribe(userId: string, controller: ReadableStreamDefaultController) {
|
||||
if (!userChannels.has(userId)) {
|
||||
userChannels.set(userId, new Set());
|
||||
}
|
||||
const conn: StreamConnection = {
|
||||
controller,
|
||||
lastPing: Date.now(),
|
||||
subscribedAt: Date.now(),
|
||||
};
|
||||
userChannels.get(userId)!.add(conn);
|
||||
|
||||
try {
|
||||
controller.enqueue(':connected\n\n');
|
||||
} catch (e) {
|
||||
userChannels.get(userId)?.delete(conn);
|
||||
}
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
const connections = userChannels.get(userId);
|
||||
if (!connections?.has(conn)) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
sendPing(conn, (e) => {
|
||||
console.log('Failed to send ping, cleaning up');
|
||||
clearInterval(pingInterval);
|
||||
this.unsubscribe(userId, controller);
|
||||
});
|
||||
}, PING_INTERVAL);
|
||||
|
||||
(conn as any).pingInterval = pingInterval;
|
||||
},
|
||||
|
||||
unsubscribe(userId: string, controller: ReadableStreamDefaultController) {
|
||||
const connections = userChannels.get(userId);
|
||||
if (!connections) return;
|
||||
|
||||
for (const conn of connections) {
|
||||
if (conn.controller === controller) {
|
||||
clearInterval((conn as any).pingInterval);
|
||||
connections.delete(conn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (connections.size === 0) {
|
||||
userChannels.delete(userId);
|
||||
}
|
||||
},
|
||||
|
||||
async emit(userId: string, entity: string, event: { op: string; payload: any; timestamp?: number }) {
|
||||
const connections = userChannels.get(userId);
|
||||
if (!connections || connections.size === 0) return;
|
||||
|
||||
const timestamp = event.timestamp ?? Date.now();
|
||||
const message = `data: ${JSON.stringify({
|
||||
entity,
|
||||
op: event.op,
|
||||
payload: event.payload,
|
||||
timestamp,
|
||||
})}\n\n`;
|
||||
|
||||
for (const conn of Array.from(connections)) {
|
||||
try {
|
||||
conn.controller.enqueue(message);
|
||||
} catch (e) {
|
||||
console.log('Failed to emit to connection, cleaning up');
|
||||
clearInterval((conn as any).pingInterval);
|
||||
connections.delete(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const topicSubscribers = new Map<string, Set<StreamConnection>>();
|
||||
|
||||
export const topicEvents = {
|
||||
subscribe(topicId: string, controller: ReadableStreamDefaultController) {
|
||||
if (!topicSubscribers.has(topicId)) {
|
||||
topicSubscribers.set(topicId, new Set());
|
||||
}
|
||||
const conn: StreamConnection = {
|
||||
controller,
|
||||
lastPing: Date.now(),
|
||||
subscribedAt: Date.now(),
|
||||
};
|
||||
topicSubscribers.get(topicId)!.add(conn);
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections?.has(conn)) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
sendPing(conn, (e) => {
|
||||
console.log('Failed to send ping, cleaning up');
|
||||
clearInterval(pingInterval);
|
||||
this.unsubscribe(topicId, controller);
|
||||
});
|
||||
}, PING_INTERVAL);
|
||||
|
||||
(conn as any).pingInterval = pingInterval;
|
||||
},
|
||||
|
||||
unsubscribe(topicId: string, controller: ReadableStreamDefaultController) {
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections) return;
|
||||
|
||||
for (const conn of connections) {
|
||||
if (conn.controller === controller) {
|
||||
clearInterval((conn as any).pingInterval);
|
||||
connections.delete(conn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (connections.size === 0) {
|
||||
topicSubscribers.delete(topicId);
|
||||
}
|
||||
},
|
||||
|
||||
async emit(topicId: string, events: Array<{ type: string; payload: any; timestamp?: number }> | { type: string; payload: any; timestamp?: number }) {
|
||||
const connections = topicSubscribers.get(topicId);
|
||||
if (!connections || connections.size === 0) return;
|
||||
|
||||
if (!Array.isArray(events)) {
|
||||
events = [events];
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
const timestamp = event.timestamp ?? Date.now();
|
||||
const data = `data: ${JSON.stringify({ ...event, timestamp })}\n\n`;
|
||||
|
||||
for (const conn of Array.from(connections)) {
|
||||
try {
|
||||
conn.controller.enqueue(data);
|
||||
} catch (e) {
|
||||
clearInterval((conn as any).pingInterval);
|
||||
connections.delete(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
+14
-16
@@ -3,31 +3,29 @@ interface PendingRename {
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const pendingRenames: Map<string, PendingRename> = new Map();
|
||||
const pendingRenames: Map<string, AbortController> = new Map();
|
||||
|
||||
export const cancelPendingRename = (renameId: string): [boolean, PendingRename?] => {
|
||||
const pendingRename = pendingRenames.get(renameId);
|
||||
if (pendingRename?.abortController) {
|
||||
pendingRename.abortController.abort();
|
||||
pendingRenames.delete(renameId);
|
||||
return [true, pendingRename];
|
||||
export const cancelPendingRename = (topicId: string) => {
|
||||
const pendingRename = pendingRenames.get(topicId);
|
||||
if (pendingRename) {
|
||||
pendingRename.abort();
|
||||
pendingRenames.delete(topicId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return [false, undefined];
|
||||
return false;
|
||||
};
|
||||
|
||||
export const completeRename = (renameId: string) => {
|
||||
const controller = pendingRenames.get(renameId);
|
||||
export const completeRename = (topicId: string) => {
|
||||
const controller = pendingRenames.get(topicId);
|
||||
if (controller) {
|
||||
pendingRenames.delete(renameId);
|
||||
pendingRenames.delete(topicId);
|
||||
}
|
||||
};
|
||||
|
||||
export const addPendingRename = (topicId: string): [string, PendingRename] => {
|
||||
const id = crypto.randomUUID();
|
||||
export const addPendingRename = (topicId: string) => {
|
||||
const abortController = new AbortController();
|
||||
const pendingRename = { topicId, abortController };
|
||||
|
||||
pendingRenames.set(id, pendingRename);
|
||||
return [id, pendingRename];
|
||||
pendingRenames.set(topicId, abortController);
|
||||
return abortController;
|
||||
};
|
||||
Reference in New Issue
Block a user