mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
adds eslint
This commit is contained in:

committed by
Geoff Seemueller

parent
9698fc6f3b
commit
02c3253343
@@ -1,24 +1,20 @@
|
||||
import { types } from "mobx-state-tree";
|
||||
import renderPage from "@open-gsio/client/server";
|
||||
import renderPage from '@open-gsio/client/server';
|
||||
import { types } from 'mobx-state-tree';
|
||||
|
||||
export default types
|
||||
.model("StaticAssetStore", {})
|
||||
.volatile((self) => ({
|
||||
.model('StaticAssetStore', {})
|
||||
.volatile(self => ({
|
||||
env: {} as Env,
|
||||
ctx: {} as ExecutionContext,
|
||||
}))
|
||||
.actions((self) => ({
|
||||
.actions(self => ({
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
},
|
||||
setCtx(ctx: ExecutionContext) {
|
||||
self.ctx = ctx;
|
||||
},
|
||||
async handleSsr(
|
||||
url: string,
|
||||
headers: Headers,
|
||||
env: Vike.PageContext.env,
|
||||
) {
|
||||
async handleSsr(url: string, headers: Headers, env: Vike.PageContext.env) {
|
||||
const pageContextInit = {
|
||||
urlOriginal: url,
|
||||
headersOriginal: headers,
|
||||
@@ -29,7 +25,6 @@ export default types
|
||||
const pageContext = await renderPage(pageContextInit);
|
||||
const { httpResponse } = pageContext;
|
||||
|
||||
|
||||
if (!httpResponse) {
|
||||
return null;
|
||||
} else {
|
||||
@@ -41,8 +36,8 @@ export default types
|
||||
try {
|
||||
return await env.ASSETS.fetch(request);
|
||||
} catch (error) {
|
||||
console.error("Error serving static asset:", error);
|
||||
return new Response("Asset not found", { status: 404 });
|
||||
console.error('Error serving static asset:', error);
|
||||
return new Response('Asset not found', { status: 404 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
@@ -1,442 +1,440 @@
|
||||
import {flow, getSnapshot, types} from 'mobx-state-tree';
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { flow, getSnapshot, types } from 'mobx-state-tree';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
import ChatSdk from '../lib/chat-sdk';
|
||||
import Message from "../models/Message";
|
||||
import O1Message from "../models/O1Message";
|
||||
import {OpenAiChatSdk} from "../providers/openai";
|
||||
import {GroqChatSdk} from "../providers/groq";
|
||||
import {ClaudeChatSdk} from "../providers/claude";
|
||||
import {FireworksAiChatSdk} from "../providers/fireworks";
|
||||
import handleStreamData from "../lib/handleStreamData";
|
||||
import {GoogleChatSdk} from "../providers/google";
|
||||
import {XaiChatSdk} from "../providers/xai";
|
||||
import {CerebrasSdk} from "../providers/cerebras";
|
||||
import {CloudflareAISdk} from "../providers/cloudflareAi";
|
||||
import {OllamaChatSdk} from "../providers/ollama";
|
||||
import {MlxOmniChatProvider, MlxOmniChatSdk} from "../providers/mlx-omni";
|
||||
import {ProviderRepository} from "../providers/_ProviderRepository";
|
||||
import handleStreamData from '../lib/handleStreamData';
|
||||
import Message from '../models/Message';
|
||||
import O1Message from '../models/O1Message';
|
||||
import { ProviderRepository } from '../providers/_ProviderRepository';
|
||||
import { CerebrasSdk } from '../providers/cerebras';
|
||||
import { ClaudeChatSdk } from '../providers/claude';
|
||||
import { CloudflareAISdk } from '../providers/cloudflareAi';
|
||||
import { FireworksAiChatSdk } from '../providers/fireworks';
|
||||
import { GoogleChatSdk } from '../providers/google';
|
||||
import { GroqChatSdk } from '../providers/groq';
|
||||
import { MlxOmniChatProvider, MlxOmniChatSdk } from '../providers/mlx-omni';
|
||||
import { OllamaChatSdk } from '../providers/ollama';
|
||||
import { OpenAiChatSdk } from '../providers/openai';
|
||||
import { XaiChatSdk } from '../providers/xai';
|
||||
|
||||
export interface StreamParams {
|
||||
env: Env;
|
||||
openai: OpenAI;
|
||||
messages: any[];
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
preprocessedContext: any;
|
||||
maxTokens: number;
|
||||
env: Env;
|
||||
openai: OpenAI;
|
||||
messages: any[];
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
preprocessedContext: any;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
const activeStreamType = types.model({
|
||||
name: types.optional(types.string, ""),
|
||||
maxTokens: types.optional(types.number, 0),
|
||||
systemPrompt: types.optional(types.string, ""),
|
||||
model: types.optional(types.string, ""),
|
||||
messages: types.optional(types.array(types.frozen()), []),
|
||||
name: types.optional(types.string, ''),
|
||||
maxTokens: types.optional(types.number, 0),
|
||||
systemPrompt: types.optional(types.string, ''),
|
||||
model: types.optional(types.string, ''),
|
||||
messages: types.optional(types.array(types.frozen()), []),
|
||||
});
|
||||
|
||||
const activeStreamsMap = types.map(
|
||||
activeStreamType,
|
||||
);
|
||||
const activeStreamsMap = types.map(activeStreamType);
|
||||
|
||||
const ChatService = types
|
||||
.model('ChatService', {
|
||||
openAIApiKey: types.optional(types.string, ""),
|
||||
openAIBaseURL: types.optional(types.string, ""),
|
||||
activeStreams: types.optional(
|
||||
activeStreamsMap,
|
||||
{}
|
||||
),
|
||||
maxTokens: types.number,
|
||||
systemPrompt: types.string
|
||||
})
|
||||
.volatile(self => ({
|
||||
openai: {} as OpenAI,
|
||||
env: {} as Env,
|
||||
}))
|
||||
.actions(self => {
|
||||
// Helper functions
|
||||
const createMessageInstance = (message: any) => {
|
||||
if (typeof message.content === 'string') {
|
||||
return Message.create({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
});
|
||||
.model('ChatService', {
|
||||
openAIApiKey: types.optional(types.string, ''),
|
||||
openAIBaseURL: types.optional(types.string, ''),
|
||||
activeStreams: types.optional(activeStreamsMap, {}),
|
||||
maxTokens: types.number,
|
||||
systemPrompt: types.string,
|
||||
})
|
||||
.volatile(self => ({
|
||||
openai: {} as OpenAI,
|
||||
env: {} as Env,
|
||||
}))
|
||||
.actions(self => {
|
||||
// Helper functions
|
||||
const createMessageInstance = (message: any) => {
|
||||
if (typeof message.content === 'string') {
|
||||
return Message.create({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const m = O1Message.create({
|
||||
role: message.role,
|
||||
content: message.content.map(item => ({
|
||||
type: item.type,
|
||||
text: item.text,
|
||||
})),
|
||||
});
|
||||
return m;
|
||||
}
|
||||
throw new Error('Unsupported message format');
|
||||
};
|
||||
|
||||
const createStreamParams = async (
|
||||
streamConfig: any,
|
||||
dynamicContext: any,
|
||||
durableObject: any,
|
||||
): Promise<StreamParams> => {
|
||||
return {
|
||||
env: self.env,
|
||||
openai: self.openai,
|
||||
messages: streamConfig.messages.map(createMessageInstance),
|
||||
model: streamConfig.model,
|
||||
systemPrompt: streamConfig.systemPrompt,
|
||||
preprocessedContext: getSnapshot(dynamicContext),
|
||||
maxTokens: await durableObject.dynamicMaxTokens(streamConfig.messages, 2000),
|
||||
};
|
||||
};
|
||||
|
||||
const modelHandlers = {
|
||||
openai: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
OpenAiChatSdk.handleOpenAiStream(params, dataHandler),
|
||||
groq: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
GroqChatSdk.handleGroqStream(params, dataHandler),
|
||||
claude: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
ClaudeChatSdk.handleClaudeStream(params, dataHandler),
|
||||
fireworks: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
FireworksAiChatSdk.handleFireworksStream(params, dataHandler),
|
||||
google: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
GoogleChatSdk.handleGoogleStream(params, dataHandler),
|
||||
xai: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
XaiChatSdk.handleXaiStream(params, dataHandler),
|
||||
cerebras: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
CerebrasSdk.handleCerebrasStream(params, dataHandler),
|
||||
cloudflareAI: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
CloudflareAISdk.handleCloudflareAIStream(params, dataHandler),
|
||||
ollama: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
OllamaChatSdk.handleOllamaStream(params, dataHandler),
|
||||
mlx: (params: StreamParams, dataHandler: (data: any) => any) =>
|
||||
MlxOmniChatSdk.handleMlxOmniStream(params, dataHandler),
|
||||
};
|
||||
|
||||
return {
|
||||
getSupportedModels: flow(function* (): Generator<Promise<unknown>, Response, unknown> {
|
||||
// ----- Helpers ----------------------------------------------------------
|
||||
const logger = console;
|
||||
|
||||
const useCache = true;
|
||||
|
||||
if (useCache) {
|
||||
// ----- 1. Try cached value ---------------------------------------------
|
||||
try {
|
||||
const cached = yield self.env.KV_STORAGE.get('supportedModels');
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached as string);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
logger.info('Cache hit – returning supportedModels from KV');
|
||||
return new Response(JSON.stringify(parsed), { status: 200 });
|
||||
}
|
||||
logger.warn('Cache entry malformed – refreshing');
|
||||
throw new Error('Malformed cache entry');
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const m = O1Message.create({
|
||||
role: message.role,
|
||||
content: message.content.map(item => ({
|
||||
type: item.type,
|
||||
text: item.text
|
||||
})),
|
||||
});
|
||||
return m;
|
||||
} catch (err) {
|
||||
logger.warn('Error reading/parsing supportedModels cache', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 2. Build fresh list ---------------------------------------------
|
||||
const providerRepo = new ProviderRepository(self.env);
|
||||
const providers = providerRepo.getProviders();
|
||||
|
||||
const providerModels = new Map<string, any[]>();
|
||||
const modelMeta = new Map<string, any>();
|
||||
|
||||
for (const provider of providers) {
|
||||
if (!provider.key) continue;
|
||||
|
||||
logger.info(`Fetching models from «${provider.endpoint}»`);
|
||||
|
||||
const openai = new OpenAI({ apiKey: provider.key, baseURL: provider.endpoint });
|
||||
|
||||
// 2‑a. List models
|
||||
try {
|
||||
const listResp = yield openai.models.list(); // <‑‑ async
|
||||
const models = 'data' in listResp ? listResp.data : listResp;
|
||||
providerModels.set(provider.name, models);
|
||||
|
||||
// 2‑b. Retrieve metadata
|
||||
for (const mdl of models) {
|
||||
try {
|
||||
const meta = yield openai.models.retrieve(mdl.id); // <‑‑ async
|
||||
modelMeta.set(mdl.id, { ...mdl, ...meta });
|
||||
} catch (err) {
|
||||
// logger.error(`Metadata fetch failed for ${mdl.id}`, err);
|
||||
modelMeta.set(mdl.id, { provider: provider.name, mdl });
|
||||
}
|
||||
}
|
||||
throw new Error('Unsupported message format');
|
||||
} catch (err) {
|
||||
logger.error(`Model list failed for provider «${provider.name}»`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 3. Merge results -------------------------------------------------
|
||||
const resultMap = new Map<string, any>();
|
||||
for (const [provName, models] of providerModels) {
|
||||
for (const mdl of models) {
|
||||
resultMap.set(mdl.id, {
|
||||
id: mdl.id,
|
||||
provider: provName,
|
||||
...(modelMeta.get(mdl.id) ?? mdl),
|
||||
});
|
||||
}
|
||||
}
|
||||
const resultArr = Array.from(resultMap.values());
|
||||
|
||||
// ----- 4. Cache fresh list ---------------------------------------------
|
||||
try {
|
||||
yield self.env.KV_STORAGE.put(
|
||||
'supportedModels',
|
||||
JSON.stringify(resultArr),
|
||||
{ expirationTtl: 60 * 60 * 24 }, // 24 h
|
||||
);
|
||||
logger.info('supportedModels cache refreshed');
|
||||
} catch (err) {
|
||||
logger.error('KV put failed for supportedModels', err);
|
||||
}
|
||||
|
||||
// ----- 5. Return --------------------------------------------------------
|
||||
return new Response(JSON.stringify(resultArr), { status: 200 });
|
||||
}),
|
||||
setActiveStream(streamId: string, stream: any) {
|
||||
const validStream = {
|
||||
name: stream?.name || 'Unnamed Stream',
|
||||
maxTokens: stream?.maxTokens || 0,
|
||||
systemPrompt: stream?.systemPrompt || '',
|
||||
model: stream?.model || '',
|
||||
messages: stream?.messages || [],
|
||||
};
|
||||
|
||||
self.activeStreams.set(streamId, validStream);
|
||||
},
|
||||
|
||||
const createStreamParams = async (
|
||||
streamConfig: any,
|
||||
dynamicContext: any,
|
||||
durableObject: any
|
||||
): Promise<StreamParams> => {
|
||||
removeActiveStream(streamId: string) {
|
||||
self.activeStreams.delete(streamId);
|
||||
},
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
|
||||
return {
|
||||
env: self.env,
|
||||
openai: self.openai,
|
||||
messages: streamConfig.messages.map(createMessageInstance),
|
||||
model: streamConfig.model,
|
||||
systemPrompt: streamConfig.systemPrompt,
|
||||
preprocessedContext: getSnapshot(dynamicContext),
|
||||
maxTokens: await durableObject.dynamicMaxTokens(
|
||||
streamConfig.messages,
|
||||
2000
|
||||
),
|
||||
if (env.OPENAI_API_ENDPOINT && env.OPENAI_API_ENDPOINT.includes('localhost')) {
|
||||
self.openai = new OpenAI({
|
||||
apiKey: self.env.OPENAI_API_KEY,
|
||||
baseURL: self.env.OPENAI_API_ENDPOINT,
|
||||
});
|
||||
} else {
|
||||
self.openai = new OpenAI({
|
||||
apiKey: self.openAIApiKey,
|
||||
baseURL: self.openAIBaseURL,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleChatRequest: async (request: Request) => {
|
||||
return ChatSdk.handleChatRequest(request, {
|
||||
openai: self.openai,
|
||||
env: self.env,
|
||||
systemPrompt: self.systemPrompt,
|
||||
maxTokens: self.maxTokens,
|
||||
});
|
||||
},
|
||||
|
||||
async runModelHandler(params: {
|
||||
streamConfig: any;
|
||||
streamParams: any;
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
streamId: string;
|
||||
}) {
|
||||
const { streamConfig, streamParams, controller, encoder, streamId } = params;
|
||||
|
||||
const modelFamily = await ProviderRepository.getModelFamily(streamConfig.model, self.env);
|
||||
|
||||
const useModelHandler = () => {
|
||||
return modelHandlers[modelFamily];
|
||||
};
|
||||
|
||||
const handler = useModelHandler();
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
await handler(streamParams, handleStreamData(controller, encoder));
|
||||
} catch (error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (
|
||||
message.includes('413 ') ||
|
||||
message.includes('maximum') ||
|
||||
message.includes('too long') ||
|
||||
message.includes('too large')
|
||||
) {
|
||||
throw new ClientError(
|
||||
`Error! Content length exceeds limits. Try shortening your message or editing an earlier message.`,
|
||||
413,
|
||||
{
|
||||
model: streamConfig.model,
|
||||
maxTokens: streamParams.maxTokens,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
if (message.includes('429 ')) {
|
||||
throw new ClientError(
|
||||
`Error! Rate limit exceeded. Wait a few minutes before trying again.`,
|
||||
429,
|
||||
{
|
||||
model: streamConfig.model,
|
||||
maxTokens: streamParams.maxTokens,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (message.includes('404')) {
|
||||
throw new ClientError(`Something went wrong, try again.`, 413, {});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
const modelHandlers = {
|
||||
openai: (params: StreamParams, dataHandler: Function) =>
|
||||
OpenAiChatSdk.handleOpenAiStream(params, dataHandler),
|
||||
groq: (params: StreamParams, dataHandler: Function) =>
|
||||
GroqChatSdk.handleGroqStream(params, dataHandler),
|
||||
claude: (params: StreamParams, dataHandler: Function) =>
|
||||
ClaudeChatSdk.handleClaudeStream(params, dataHandler),
|
||||
fireworks: (params: StreamParams, dataHandler: Function) =>
|
||||
FireworksAiChatSdk.handleFireworksStream(params, dataHandler),
|
||||
google: (params: StreamParams, dataHandler: Function) =>
|
||||
GoogleChatSdk.handleGoogleStream(params, dataHandler),
|
||||
xai: (params: StreamParams, dataHandler: Function) =>
|
||||
XaiChatSdk.handleXaiStream(params, dataHandler),
|
||||
cerebras: (params: StreamParams, dataHandler: Function) =>
|
||||
CerebrasSdk.handleCerebrasStream(params, dataHandler),
|
||||
cloudflareAI: (params: StreamParams, dataHandler: Function) =>
|
||||
CloudflareAISdk.handleCloudflareAIStream(params, dataHandler),
|
||||
ollama: (params: StreamParams, dataHandler: Function) =>
|
||||
OllamaChatSdk.handleOllamaStream(params, dataHandler),
|
||||
mlx: (params: StreamParams, dataHandler: Function) =>
|
||||
MlxOmniChatSdk.handleMlxOmniStream(params, dataHandler),
|
||||
};
|
||||
createSseReadableStream(params: {
|
||||
streamId: string;
|
||||
streamConfig: any;
|
||||
savedStreamConfig: string;
|
||||
durableObject: any;
|
||||
}) {
|
||||
const { streamId, streamConfig, savedStreamConfig, durableObject } = params;
|
||||
|
||||
return {
|
||||
getSupportedModels: flow(function* ():
|
||||
Generator<Promise<unknown>, Response, unknown> {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// ----- Helpers ----------------------------------------------------------
|
||||
const logger = console;
|
||||
try {
|
||||
const dynamicContext = Message.create(streamConfig.preprocessedContext);
|
||||
|
||||
const useCache = true;
|
||||
// Process the stream data using the appropriate handler
|
||||
const streamParams = await createStreamParams(
|
||||
streamConfig,
|
||||
dynamicContext,
|
||||
durableObject,
|
||||
);
|
||||
|
||||
if(useCache) {
|
||||
// ----- 1. Try cached value ---------------------------------------------
|
||||
try {
|
||||
const cached = yield self.env.KV_STORAGE.get('supportedModels');
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached as string);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
logger.info('Cache hit – returning supportedModels from KV');
|
||||
return new Response(JSON.stringify(parsed), { status: 200 });
|
||||
}
|
||||
logger.warn('Cache entry malformed – refreshing');
|
||||
throw new Error('Malformed cache entry');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error reading/parsing supportedModels cache', err);
|
||||
}
|
||||
}
|
||||
await self.runModelHandler({
|
||||
streamConfig,
|
||||
streamParams,
|
||||
controller,
|
||||
encoder,
|
||||
streamId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`chatService::handleSseStream::${streamId}::Error`, error);
|
||||
|
||||
|
||||
// ----- 2. Build fresh list ---------------------------------------------
|
||||
const providerRepo = new ProviderRepository(self.env);
|
||||
const providers = providerRepo.getProviders();
|
||||
|
||||
const providerModels = new Map<string, any[]>();
|
||||
const modelMeta = new Map<string, any>();
|
||||
|
||||
for (const provider of providers) {
|
||||
if (!provider.key) continue;
|
||||
|
||||
logger.info(`Fetching models from «${provider.endpoint}»`);
|
||||
|
||||
const openai = new OpenAI({ apiKey: provider.key, baseURL: provider.endpoint });
|
||||
|
||||
// 2‑a. List models
|
||||
try {
|
||||
const listResp = yield openai.models.list(); // <‑‑ async
|
||||
const models = ('data' in listResp) ? listResp.data : listResp;
|
||||
providerModels.set(provider.name, models);
|
||||
|
||||
// 2‑b. Retrieve metadata
|
||||
for (const mdl of models) {
|
||||
try {
|
||||
const meta = yield openai.models.retrieve(mdl.id); // <‑‑ async
|
||||
modelMeta.set(mdl.id, { ...mdl, ...meta });
|
||||
} catch (err) {
|
||||
// logger.error(`Metadata fetch failed for ${mdl.id}`, err);
|
||||
modelMeta.set(mdl.id, {provider: provider.name, mdl});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Model list failed for provider «${provider.name}»`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 3. Merge results -------------------------------------------------
|
||||
const resultMap = new Map<string, any>();
|
||||
for (const [provName, models] of providerModels) {
|
||||
for (const mdl of models) {
|
||||
resultMap.set(mdl.id, {
|
||||
id: mdl.id,
|
||||
provider: provName,
|
||||
...(modelMeta.get(mdl.id) ?? mdl),
|
||||
});
|
||||
}
|
||||
}
|
||||
const resultArr = Array.from(resultMap.values());
|
||||
|
||||
// ----- 4. Cache fresh list ---------------------------------------------
|
||||
try {
|
||||
yield self.env.KV_STORAGE.put(
|
||||
'supportedModels',
|
||||
JSON.stringify(resultArr),
|
||||
{ expirationTtl: 60 * 60 * 24 }, // 24 h
|
||||
);
|
||||
logger.info('supportedModels cache refreshed');
|
||||
} catch (err) {
|
||||
logger.error('KV put failed for supportedModels', err);
|
||||
}
|
||||
|
||||
// ----- 5. Return --------------------------------------------------------
|
||||
return new Response(JSON.stringify(resultArr), { status: 200 });
|
||||
}),
|
||||
setActiveStream(streamId: string, stream: any) {
|
||||
const validStream = {
|
||||
name: stream?.name || "Unnamed Stream",
|
||||
maxTokens: stream?.maxTokens || 0,
|
||||
systemPrompt: stream?.systemPrompt || "",
|
||||
model: stream?.model || "",
|
||||
messages: stream?.messages || [],
|
||||
};
|
||||
|
||||
self.activeStreams.set(streamId, validStream);
|
||||
},
|
||||
|
||||
removeActiveStream(streamId: string) {
|
||||
self.activeStreams.delete(streamId);
|
||||
},
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
|
||||
if(env.OPENAI_API_ENDPOINT && env.OPENAI_API_ENDPOINT.includes("localhost")) {
|
||||
self.openai = new OpenAI({
|
||||
apiKey: self.env.OPENAI_API_KEY,
|
||||
baseURL: self.env.OPENAI_API_ENDPOINT,
|
||||
});
|
||||
} else{
|
||||
self.openai = new OpenAI({
|
||||
apiKey: self.openAIApiKey,
|
||||
baseURL: self.openAIBaseURL,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleChatRequest: async (request: Request) => {
|
||||
return ChatSdk.handleChatRequest(request, {
|
||||
openai: self.openai,
|
||||
env: self.env,
|
||||
systemPrompt: self.systemPrompt,
|
||||
maxTokens: self.maxTokens
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
async runModelHandler(params: {
|
||||
streamConfig: any;
|
||||
streamParams: any;
|
||||
controller: ReadableStreamDefaultController;
|
||||
encoder: TextEncoder;
|
||||
streamId: string;
|
||||
}) {
|
||||
const {streamConfig, streamParams, controller, encoder, streamId} = params;
|
||||
|
||||
|
||||
const modelFamily = await ProviderRepository.getModelFamily(streamConfig.model, self.env);
|
||||
|
||||
const useModelHandler = () => {
|
||||
return modelHandlers[modelFamily]
|
||||
}
|
||||
|
||||
const handler = useModelHandler();
|
||||
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
await handler(streamParams, handleStreamData(controller, encoder));
|
||||
|
||||
} catch (error) {
|
||||
const message = error.message.toLowerCase();
|
||||
|
||||
if (message.includes("413 ") || (message.includes("maximum") || message.includes("too long") || message.includes("too large"))) {
|
||||
throw new ClientError(`Error! Content length exceeds limits. Try shortening your message or editing an earlier message.`, 413, {
|
||||
model: streamConfig.model,
|
||||
maxTokens: streamParams.maxTokens
|
||||
})
|
||||
}
|
||||
if (message.includes("429 ")) {
|
||||
throw new ClientError(`Error! Rate limit exceeded. Wait a few minutes before trying again.`, 429, {
|
||||
model: streamConfig.model,
|
||||
maxTokens: streamParams.maxTokens
|
||||
})
|
||||
}
|
||||
if (message.includes("404")) {
|
||||
throw new ClientError(`Something went wrong, try again.`, 413, {})
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
createSseReadableStream(params: {
|
||||
streamId: string;
|
||||
streamConfig: any;
|
||||
savedStreamConfig: string;
|
||||
durableObject: any;
|
||||
}) {
|
||||
const {streamId, streamConfig, savedStreamConfig, durableObject} = params;
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
try {
|
||||
const dynamicContext = Message.create(streamConfig.preprocessedContext);
|
||||
|
||||
// Process the stream data using the appropriate handler
|
||||
const streamParams = await createStreamParams(
|
||||
streamConfig,
|
||||
dynamicContext,
|
||||
durableObject
|
||||
);
|
||||
|
||||
try {
|
||||
await self.runModelHandler({
|
||||
streamConfig,
|
||||
streamParams,
|
||||
controller,
|
||||
encoder,
|
||||
streamId,
|
||||
});
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`chatService::handleSseStream::${streamId}::Error`, error);
|
||||
|
||||
if (error instanceof ClientError) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({type: 'error', error: error.message})}\n\n`)
|
||||
);
|
||||
} else {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: "Server error"
|
||||
})}\n\n`)
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
} finally {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (_) {
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
handleSseStream: flow(function* (streamId: string): Generator<Promise<string>, Response, unknown> {
|
||||
// Check if a stream is already active for this ID
|
||||
if (self.activeStreams.has(streamId)) {
|
||||
return new Response('Stream already active', {status: 409});
|
||||
}
|
||||
|
||||
// Retrieve the stream configuration from the durable object
|
||||
const objectId = self.env.SERVER_COORDINATOR.idFromName('stream-index');
|
||||
const durableObject = self.env.SERVER_COORDINATOR.get(objectId);
|
||||
const savedStreamConfig = yield durableObject.getStreamData(streamId);
|
||||
|
||||
if (!savedStreamConfig) {
|
||||
return new Response('Stream not found', {status: 404});
|
||||
}
|
||||
|
||||
const streamConfig = JSON.parse(savedStreamConfig);
|
||||
|
||||
const stream = self.createSseReadableStream({
|
||||
streamId,
|
||||
streamConfig,
|
||||
savedStreamConfig,
|
||||
durableObject,
|
||||
});
|
||||
|
||||
// Use `tee()` to create two streams: one for processing and one for the response
|
||||
const [processingStream, responseStream] = stream.tee();
|
||||
|
||||
self.setActiveStream(streamId, {
|
||||
...streamConfig,
|
||||
});
|
||||
|
||||
processingStream.pipeTo(
|
||||
new WritableStream({
|
||||
close() {
|
||||
self.removeActiveStream(streamId);
|
||||
},
|
||||
})
|
||||
if (error instanceof ClientError) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'Server error',
|
||||
})}\n\n`,
|
||||
),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
} finally {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (_) {
|
||||
// Ignore errors when closing the controller, as it might already be closed
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Return the second stream as the response
|
||||
return new Response(responseStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
handleSseStream: flow(function* (
|
||||
streamId: string,
|
||||
): Generator<Promise<string>, Response, unknown> {
|
||||
// Check if a stream is already active for this ID
|
||||
if (self.activeStreams.has(streamId)) {
|
||||
return new Response('Stream already active', { status: 409 });
|
||||
}
|
||||
|
||||
// Retrieve the stream configuration from the durable object
|
||||
const objectId = self.env.SERVER_COORDINATOR.idFromName('stream-index');
|
||||
const durableObject = self.env.SERVER_COORDINATOR.get(objectId);
|
||||
const savedStreamConfig = yield durableObject.getStreamData(streamId);
|
||||
|
||||
if (!savedStreamConfig) {
|
||||
return new Response('Stream not found', { status: 404 });
|
||||
}
|
||||
|
||||
const streamConfig = JSON.parse(savedStreamConfig);
|
||||
|
||||
const stream = self.createSseReadableStream({
|
||||
streamId,
|
||||
streamConfig,
|
||||
savedStreamConfig,
|
||||
durableObject,
|
||||
});
|
||||
|
||||
// Use `tee()` to create two streams: one for processing and one for the response
|
||||
const [processingStream, responseStream] = stream.tee();
|
||||
|
||||
self.setActiveStream(streamId, {
|
||||
...streamConfig,
|
||||
});
|
||||
|
||||
processingStream.pipeTo(
|
||||
new WritableStream({
|
||||
close() {
|
||||
self.removeActiveStream(streamId);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Return the second stream as the response
|
||||
return new Response(responseStream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* ClientError
|
||||
* A custom construct for sending client-friendly errors via the controller in a structured and controlled manner.
|
||||
*/
|
||||
export class ClientError extends Error {
|
||||
public statusCode: number;
|
||||
public details: Record<string, any>;
|
||||
public statusCode: number;
|
||||
public details: Record<string, any>;
|
||||
|
||||
constructor(message: string, statusCode: number, details: Record<string, any> = {}) {
|
||||
super(message);
|
||||
this.name = 'ClientError';
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
Object.setPrototypeOf(this, ClientError.prototype);
|
||||
}
|
||||
constructor(message: string, statusCode: number, details: Record<string, any> = {}) {
|
||||
super(message);
|
||||
this.name = 'ClientError';
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
Object.setPrototypeOf(this, ClientError.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the error for SSE-compatible data transmission.
|
||||
*/
|
||||
public formatForSSE(): string {
|
||||
return JSON.stringify({
|
||||
type: 'error',
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
statusCode: this.statusCode,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Formats the error for SSE-compatible data transmission.
|
||||
*/
|
||||
public formatForSSE(): string {
|
||||
return JSON.stringify({
|
||||
type: 'error',
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
statusCode: this.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatService;
|
||||
|
@@ -1,14 +1,15 @@
|
||||
// ContactService.ts
|
||||
import { types, flow, getSnapshot } from "mobx-state-tree";
|
||||
import ContactRecord from "../models/ContactRecord.ts";
|
||||
import { types, flow, getSnapshot } from 'mobx-state-tree';
|
||||
|
||||
import ContactRecord from '../models/ContactRecord.ts';
|
||||
|
||||
export default types
|
||||
.model("ContactStore", {})
|
||||
.volatile((self) => ({
|
||||
.model('ContactStore', {})
|
||||
.volatile(self => ({
|
||||
env: {} as Env,
|
||||
ctx: {} as ExecutionContext,
|
||||
}))
|
||||
.actions((self) => ({
|
||||
.actions(self => ({
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
},
|
||||
@@ -17,12 +18,7 @@ export default types
|
||||
},
|
||||
handleContact: flow(function* (request: Request) {
|
||||
try {
|
||||
const {
|
||||
markdown: message,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
} = yield request.json();
|
||||
const { markdown: message, email, firstname, lastname } = yield request.json();
|
||||
const contactRecord = ContactRecord.create({
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -37,19 +33,19 @@ export default types
|
||||
);
|
||||
|
||||
yield self.env.EMAIL_SERVICE.sendMail({
|
||||
to: "geoff@seemueller.io",
|
||||
to: 'geoff@seemueller.io',
|
||||
plaintextMessage: `WEBSITE CONTACT FORM SUBMISSION
|
||||
${firstname} ${lastname}
|
||||
${email}
|
||||
${message}`,
|
||||
});
|
||||
|
||||
return new Response("Contact record saved successfully", {
|
||||
return new Response('Contact record saved successfully', {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing contact request:", error);
|
||||
return new Response("Failed to process contact request", {
|
||||
console.error('Error processing contact request:', error);
|
||||
return new Response('Failed to process contact request', {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import { types, flow, getSnapshot } from "mobx-state-tree";
|
||||
import FeedbackRecord from "../models/FeedbackRecord.ts";
|
||||
import { types, flow, getSnapshot } from 'mobx-state-tree';
|
||||
|
||||
import FeedbackRecord from '../models/FeedbackRecord.ts';
|
||||
|
||||
export default types
|
||||
.model("FeedbackStore", {})
|
||||
.volatile((self) => ({
|
||||
.model('FeedbackStore', {})
|
||||
.volatile(self => ({
|
||||
env: {} as Env,
|
||||
ctx: {} as ExecutionContext,
|
||||
}))
|
||||
.actions((self) => ({
|
||||
.actions(self => ({
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
},
|
||||
@@ -19,7 +20,7 @@ export default types
|
||||
const {
|
||||
feedback,
|
||||
timestamp = new Date().toISOString(),
|
||||
user = "Anonymous",
|
||||
user = 'Anonymous',
|
||||
} = yield request.json();
|
||||
|
||||
const feedbackRecord = FeedbackRecord.create({
|
||||
@@ -35,17 +36,17 @@ export default types
|
||||
);
|
||||
|
||||
yield self.env.EMAIL_SERVICE.sendMail({
|
||||
to: "geoff@seemueller.io",
|
||||
to: 'geoff@seemueller.io',
|
||||
plaintextMessage: `NEW FEEDBACK SUBMISSION
|
||||
User: ${user}
|
||||
Feedback: ${feedback}
|
||||
Timestamp: ${timestamp}`,
|
||||
});
|
||||
|
||||
return new Response("Feedback saved successfully", { status: 200 });
|
||||
return new Response('Feedback saved successfully', { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error processing feedback request:", error);
|
||||
return new Response("Failed to process feedback request", {
|
||||
console.error('Error processing feedback request:', error);
|
||||
return new Response('Failed to process feedback request', {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
@@ -1,14 +1,14 @@
|
||||
import { types, flow } from "mobx-state-tree";
|
||||
import { types, flow } from 'mobx-state-tree';
|
||||
|
||||
const MetricsService = types
|
||||
.model("MetricsService", {
|
||||
.model('MetricsService', {
|
||||
isCollectingMetrics: types.optional(types.boolean, true),
|
||||
})
|
||||
.volatile((self) => ({
|
||||
.volatile(self => ({
|
||||
env: {} as Env,
|
||||
ctx: {} as ExecutionContext,
|
||||
}))
|
||||
.actions((self) => ({
|
||||
.actions(self => ({
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
},
|
||||
@@ -17,35 +17,35 @@ const MetricsService = types
|
||||
},
|
||||
handleMetricsRequest: flow(function* (request: Request) {
|
||||
const url = new URL(request.url);
|
||||
let proxyUrl = "";
|
||||
if(self.env.METRICS_HOST) {
|
||||
let proxyUrl = '';
|
||||
if (self.env.METRICS_HOST) {
|
||||
proxyUrl = new URL(`${self.env.METRICS_HOST}${url.pathname}${url.search}`).toString();
|
||||
}
|
||||
|
||||
if(proxyUrl) {
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const response = yield fetch(proxyUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: ["GET", "HEAD"].includes(request.method) ? null : request.body,
|
||||
redirect: "follow",
|
||||
body: ['GET', 'HEAD'].includes(request.method) ? null : request.body,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to proxy metrics request:", error);
|
||||
return new Response("metrics misconfigured", { status: 200 });
|
||||
console.error('Failed to proxy metrics request:', error);
|
||||
return new Response('metrics misconfigured', { status: 200 });
|
||||
}
|
||||
} else {
|
||||
const event = {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: ["GET", "HEAD"].includes(request.method) ? null : request.body,
|
||||
}
|
||||
if(self.env?.KV_STORAGE?.put) {
|
||||
body: ['GET', 'HEAD'].includes(request.method) ? null : request.body,
|
||||
};
|
||||
if (self.env?.KV_STORAGE?.put) {
|
||||
self.env.KV_STORAGE.put(`metrics_events::${crypto.randomUUID()}`, JSON.stringify(event));
|
||||
} else {
|
||||
console.log("Detected metrics misconfiguration...not storing")
|
||||
console.log('Detected metrics misconfiguration...not storing');
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import { types } from "mobx-state-tree";
|
||||
import { types } from 'mobx-state-tree';
|
||||
|
||||
const TransactionService = types
|
||||
.model("TransactionService", {})
|
||||
.volatile((self) => ({
|
||||
.model('TransactionService', {})
|
||||
.volatile(self => ({
|
||||
env: {} as Env,
|
||||
ctx: {} as ExecutionContext,
|
||||
}))
|
||||
.actions((self) => ({
|
||||
.actions(self => ({
|
||||
setEnv(env: Env) {
|
||||
self.env = env;
|
||||
},
|
||||
@@ -15,7 +15,7 @@ const TransactionService = types
|
||||
},
|
||||
|
||||
routeAction: async function (action: string, requestBody: any) {
|
||||
const actionHandlers: Record<string, Function> = {
|
||||
const actionHandlers: Record<string, (data: any) => Promise<any>> = {
|
||||
PREPARE_TX: self.handlePrepareTransaction,
|
||||
};
|
||||
|
||||
@@ -30,9 +30,9 @@ const TransactionService = types
|
||||
handlePrepareTransaction: async function (data: []) {
|
||||
const [donerId, currency, amount] = data;
|
||||
const CreateWalletEndpoints = {
|
||||
bitcoin: "/api/btc/create",
|
||||
ethereum: "/api/eth/create",
|
||||
dogecoin: "/api/doge/create",
|
||||
bitcoin: '/api/btc/create',
|
||||
ethereum: '/api/eth/create',
|
||||
dogecoin: '/api/doge/create',
|
||||
};
|
||||
|
||||
const walletRequest = await fetch(
|
||||
@@ -40,8 +40,7 @@ const TransactionService = types
|
||||
);
|
||||
const walletResponse = await walletRequest.text();
|
||||
// console.log({ walletRequest: walletResponse });
|
||||
const [address, privateKey, publicKey, phrase] =
|
||||
JSON.parse(walletResponse);
|
||||
const [address, privateKey, publicKey, phrase] = JSON.parse(walletResponse);
|
||||
|
||||
const txKey = crypto.randomUUID();
|
||||
|
||||
@@ -73,19 +72,19 @@ const TransactionService = types
|
||||
try {
|
||||
const raw = await request.text();
|
||||
// console.log({ raw });
|
||||
const [action, ...payload] = raw.split(",");
|
||||
const [action, ...payload] = raw.split(',');
|
||||
|
||||
const response = await self.routeAction(action, payload);
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error handling transaction:", error);
|
||||
return new Response(JSON.stringify({ error: "Transaction failed" }), {
|
||||
console.error('Error handling transaction:', error);
|
||||
return new Response(JSON.stringify({ error: 'Transaction failed' }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getSnapshot } from 'mobx-state-tree';
|
||||
|
||||
import AssetService from '../AssetService.ts';
|
||||
|
||||
// Mock the vike/server module
|
||||
@@ -8,26 +8,27 @@ vi.mock('vike/server', () => ({
|
||||
}));
|
||||
|
||||
// Import the mocked renderPage function for assertions
|
||||
// eslint-disable-next-line import/order
|
||||
import { renderPage } from 'vike/server';
|
||||
|
||||
describe('AssetService', () => {
|
||||
let assetService;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a new instance of the service before each test
|
||||
assetService = AssetService.create();
|
||||
|
||||
|
||||
// Reset mocks
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
|
||||
describe('Initial state', () => {
|
||||
it('should have empty env and ctx objects initially', () => {
|
||||
expect(assetService.env).toEqual({});
|
||||
expect(assetService.ctx).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('setEnv', () => {
|
||||
it('should set the environment', () => {
|
||||
const mockEnv = { ASSETS: { fetch: vi.fn() } };
|
||||
@@ -35,7 +36,7 @@ describe('AssetService', () => {
|
||||
expect(assetService.env).toEqual(mockEnv);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('setCtx', () => {
|
||||
it('should set the execution context', () => {
|
||||
const mockCtx = { waitUntil: vi.fn() };
|
||||
@@ -43,18 +44,18 @@ describe('AssetService', () => {
|
||||
expect(assetService.ctx).toEqual(mockCtx);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('handleSsr', () => {
|
||||
it('should return null when httpResponse is not available', async () => {
|
||||
// Setup mock to return a pageContext without httpResponse
|
||||
vi.mocked(renderPage).mockResolvedValue({});
|
||||
|
||||
|
||||
const url = 'https://example.com';
|
||||
const headers = new Headers();
|
||||
const env = {};
|
||||
|
||||
|
||||
const result = await assetService.handleSsr(url, headers, env);
|
||||
|
||||
|
||||
// Verify renderPage was called with correct arguments
|
||||
expect(renderPage).toHaveBeenCalledWith({
|
||||
urlOriginal: url,
|
||||
@@ -62,15 +63,15 @@ describe('AssetService', () => {
|
||||
fetch: expect.any(Function),
|
||||
env,
|
||||
});
|
||||
|
||||
|
||||
// Verify result is null
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it('should return a Response when httpResponse is available', async () => {
|
||||
// Create mock stream
|
||||
const mockStream = new ReadableStream();
|
||||
|
||||
|
||||
// Setup mock to return a pageContext with httpResponse
|
||||
vi.mocked(renderPage).mockResolvedValue({
|
||||
httpResponse: {
|
||||
@@ -79,13 +80,13 @@ describe('AssetService', () => {
|
||||
getReadableWebStream: () => mockStream,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const url = 'https://example.com';
|
||||
const headers = new Headers();
|
||||
const env = {};
|
||||
|
||||
|
||||
const result = await assetService.handleSsr(url, headers, env);
|
||||
|
||||
|
||||
// Verify renderPage was called with correct arguments
|
||||
expect(renderPage).toHaveBeenCalledWith({
|
||||
urlOriginal: url,
|
||||
@@ -93,72 +94,72 @@ describe('AssetService', () => {
|
||||
fetch: expect.any(Function),
|
||||
env,
|
||||
});
|
||||
|
||||
|
||||
// Verify result is a Response with correct properties
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.headers.get('Content-Type')).toBe('text/html');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('handleStaticAssets', () => {
|
||||
it('should fetch assets from the environment', async () => {
|
||||
// Create mock request
|
||||
const request = new Request('https://example.com/static/image.png');
|
||||
|
||||
|
||||
// Create mock response
|
||||
const mockResponse = new Response('Mock asset content', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
|
||||
|
||||
// Create mock environment with ASSETS.fetch
|
||||
const mockEnv = {
|
||||
ASSETS: {
|
||||
fetch: vi.fn().mockResolvedValue(mockResponse),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Set the environment
|
||||
assetService.setEnv(mockEnv);
|
||||
|
||||
|
||||
// Call the method
|
||||
const result = await assetService.handleStaticAssets(request, mockEnv);
|
||||
|
||||
|
||||
// Verify ASSETS.fetch was called with the request
|
||||
expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request);
|
||||
|
||||
|
||||
// Verify result is the expected response
|
||||
expect(result).toBe(mockResponse);
|
||||
});
|
||||
|
||||
|
||||
it('should return a 404 response when an error occurs', async () => {
|
||||
// Create mock request
|
||||
const request = new Request('https://example.com/static/not-found.png');
|
||||
|
||||
|
||||
// Create mock environment with ASSETS.fetch that throws an error
|
||||
const mockEnv = {
|
||||
ASSETS: {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('Asset not found')),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Set the environment
|
||||
assetService.setEnv(mockEnv);
|
||||
|
||||
|
||||
// Call the method
|
||||
const result = await assetService.handleStaticAssets(request, mockEnv);
|
||||
|
||||
|
||||
// Verify ASSETS.fetch was called with the request
|
||||
expect(mockEnv.ASSETS.fetch).toHaveBeenCalledWith(request);
|
||||
|
||||
|
||||
// Verify result is a 404 Response
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(404);
|
||||
|
||||
|
||||
// Verify response body
|
||||
const text = await result.clone().text();
|
||||
expect(text).toBe('Asset not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,31 +1,28 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {getSnapshot} from 'mobx-state-tree';
|
||||
import ChatService, {ClientError} from '../ChatService.ts';
|
||||
import { getSnapshot } from 'mobx-state-tree';
|
||||
import OpenAI from 'openai';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ChatSdk from '../../lib/chat-sdk.ts';
|
||||
import ChatService, { ClientError } from '../ChatService.ts';
|
||||
// Create mock OpenAI instance
|
||||
const mockOpenAIInstance = {
|
||||
models: {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'mlx-model-1' },
|
||||
{ id: 'mlx-model-2' },
|
||||
{ id: 'other-model' }
|
||||
]
|
||||
})
|
||||
data: [{ id: 'mlx-model-1' }, { id: 'mlx-model-2' }, { id: 'other-model' }],
|
||||
}),
|
||||
},
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn()
|
||||
}
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
baseURL: 'http://localhost:8000'
|
||||
baseURL: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('openai', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => mockOpenAIInstance)
|
||||
default: vi.fn().mockImplementation(() => mockOpenAIInstance),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,12 +30,12 @@ vi.mock('../../lib/chat-sdk', () => ({
|
||||
default: {
|
||||
handleChatRequest: vi.fn(),
|
||||
buildAssistantPrompt: vi.fn(),
|
||||
buildMessageChain: vi.fn()
|
||||
}
|
||||
buildMessageChain: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/handleStreamData', () => ({
|
||||
default: vi.fn().mockReturnValue(() => {})
|
||||
default: vi.fn().mockReturnValue(() => {}),
|
||||
}));
|
||||
|
||||
describe('ChatService', () => {
|
||||
@@ -51,7 +48,7 @@ describe('ChatService', () => {
|
||||
maxTokens: 2000,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
openAIApiKey: 'test-api-key',
|
||||
openAIBaseURL: 'https://api.openai.com/v1'
|
||||
openAIBaseURL: 'https://api.openai.com/v1',
|
||||
});
|
||||
|
||||
// Create mock environment
|
||||
@@ -61,14 +58,16 @@ describe('ChatService', () => {
|
||||
SERVER_COORDINATOR: {
|
||||
idFromName: vi.fn().mockReturnValue('test-id'),
|
||||
get: vi.fn().mockReturnValue({
|
||||
getStreamData: vi.fn().mockResolvedValue(JSON.stringify({
|
||||
messages: [],
|
||||
model: 'gpt-4',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
preprocessedContext: {}
|
||||
}))
|
||||
})
|
||||
}
|
||||
getStreamData: vi.fn().mockResolvedValue(
|
||||
JSON.stringify({
|
||||
messages: [],
|
||||
model: 'gpt-4',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
preprocessedContext: {},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// Set the environment using the action
|
||||
@@ -86,7 +85,7 @@ describe('ChatService', () => {
|
||||
it('should have the correct initial state', () => {
|
||||
const freshService = ChatService.create({
|
||||
maxTokens: 2000,
|
||||
systemPrompt: 'You are a helpful assistant.'
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
});
|
||||
|
||||
expect(freshService.maxTokens).toBe(2000);
|
||||
@@ -101,7 +100,7 @@ describe('ChatService', () => {
|
||||
it('should set the environment and initialize OpenAI client with local endpoint', () => {
|
||||
const localEnv = {
|
||||
...mockEnv,
|
||||
OPENAI_API_ENDPOINT: 'http://localhost:8000'
|
||||
OPENAI_API_ENDPOINT: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
// Reset the mock to track new calls
|
||||
@@ -112,7 +111,7 @@ describe('ChatService', () => {
|
||||
expect(chatService.env).toEqual(localEnv);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: localEnv.OPENAI_API_KEY,
|
||||
baseURL: localEnv.OPENAI_API_ENDPOINT
|
||||
baseURL: localEnv.OPENAI_API_ENDPOINT,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +121,7 @@ describe('ChatService', () => {
|
||||
maxTokens: 2000,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
openAIApiKey: 'test-api-key',
|
||||
openAIBaseURL: 'https://api.openai.com/v1'
|
||||
openAIBaseURL: 'https://api.openai.com/v1',
|
||||
});
|
||||
|
||||
// Reset the mock to track new calls
|
||||
@@ -133,7 +132,7 @@ describe('ChatService', () => {
|
||||
expect(service.env).toEqual(mockEnv);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1'
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -146,7 +145,7 @@ describe('ChatService', () => {
|
||||
maxTokens: 1000,
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
model: 'gpt-4',
|
||||
messages: []
|
||||
messages: [],
|
||||
};
|
||||
|
||||
// Set active stream
|
||||
@@ -170,7 +169,7 @@ describe('ChatService', () => {
|
||||
maxTokens: 0,
|
||||
systemPrompt: '',
|
||||
model: '',
|
||||
messages: []
|
||||
messages: [],
|
||||
});
|
||||
|
||||
// Set active stream with partial data
|
||||
@@ -181,7 +180,7 @@ describe('ChatService', () => {
|
||||
maxTokens: 0,
|
||||
systemPrompt: '',
|
||||
model: '',
|
||||
messages: []
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -189,21 +188,21 @@ describe('ChatService', () => {
|
||||
describe('getSupportedModels', () => {
|
||||
it('should return local models when using localhost endpoint', async () => {
|
||||
const originalResponseJson = Response.json;
|
||||
Response.json = vi.fn().mockImplementation((data) => {
|
||||
Response.json = vi.fn().mockImplementation(data => {
|
||||
return {
|
||||
json: async () => data
|
||||
json: async () => data,
|
||||
};
|
||||
});
|
||||
|
||||
const localEnv = {
|
||||
...mockEnv,
|
||||
OPENAI_API_ENDPOINT: 'http://localhost:8000'
|
||||
OPENAI_API_ENDPOINT: 'http://localhost:8000',
|
||||
};
|
||||
|
||||
// Create a new service instance for this test
|
||||
const localService = ChatService.create({
|
||||
maxTokens: 2000,
|
||||
systemPrompt: 'You are a helpful assistant.'
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
});
|
||||
|
||||
localService.setEnv(localEnv);
|
||||
@@ -211,7 +210,7 @@ describe('ChatService', () => {
|
||||
// Mock the implementation of getSupportedModels for this test
|
||||
const originalGetSupportedModels = localService.getSupportedModels;
|
||||
localService.getSupportedModels = vi.fn().mockResolvedValueOnce({
|
||||
json: async () => ['mlx-model-1', 'mlx-model-2']
|
||||
json: async () => ['mlx-model-1', 'mlx-model-2'],
|
||||
});
|
||||
|
||||
const response = await localService.getSupportedModels();
|
||||
@@ -238,7 +237,7 @@ describe('ChatService', () => {
|
||||
openai: chatService.openai,
|
||||
env: mockEnv,
|
||||
systemPrompt: chatService.systemPrompt,
|
||||
maxTokens: chatService.maxTokens
|
||||
maxTokens: chatService.maxTokens,
|
||||
});
|
||||
|
||||
expect(result).toBe(mockResponse);
|
||||
@@ -263,7 +262,7 @@ describe('ChatService', () => {
|
||||
|
||||
// Mock the SERVER_COORDINATOR.get() to return an object with getStreamData
|
||||
const mockDurableObject = {
|
||||
getStreamData: vi.fn().mockResolvedValue(null)
|
||||
getStreamData: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
// Update the mockEnv to use our mock
|
||||
@@ -271,8 +270,8 @@ describe('ChatService', () => {
|
||||
...mockEnv,
|
||||
SERVER_COORDINATOR: {
|
||||
idFromName: vi.fn().mockReturnValue('test-id'),
|
||||
get: vi.fn().mockReturnValue(mockDurableObject)
|
||||
}
|
||||
get: vi.fn().mockReturnValue(mockDurableObject),
|
||||
},
|
||||
};
|
||||
|
||||
// Set the environment
|
||||
@@ -290,15 +289,15 @@ describe('ChatService', () => {
|
||||
// Create a new service instance for this test
|
||||
const testService = ChatService.create({
|
||||
maxTokens: 2000,
|
||||
systemPrompt: 'You are a helpful assistant.'
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
});
|
||||
|
||||
// Set up minimal environment
|
||||
testService.setEnv({
|
||||
SERVER_COORDINATOR: {
|
||||
idFromName: vi.fn(),
|
||||
get: vi.fn()
|
||||
}
|
||||
get: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
// Save the original method
|
||||
@@ -310,10 +309,10 @@ describe('ChatService', () => {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
status: 200,
|
||||
text: vi.fn().mockResolvedValue('')
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
});
|
||||
|
||||
const result = await testService.handleSseStream(streamId);
|
||||
@@ -349,7 +348,7 @@ describe('ChatService', () => {
|
||||
type: 'error',
|
||||
message: 'Test error',
|
||||
details: { detail: 'test' },
|
||||
statusCode: 400
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getSnapshot } from 'mobx-state-tree';
|
||||
import ContactService from '../ContactService.ts';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import ContactRecord from '../../models/ContactRecord.ts';
|
||||
import ContactService from '../ContactService.ts';
|
||||
|
||||
describe('ContactService', () => {
|
||||
let contactService;
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getSnapshot } from 'mobx-state-tree';
|
||||
import FeedbackService from '../FeedbackService.ts';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import FeedbackRecord from '../../models/FeedbackRecord.ts';
|
||||
import FeedbackService from '../FeedbackService.ts';
|
||||
|
||||
describe('FeedbackService', () => {
|
||||
let feedbackService;
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import MetricsService from "../MetricsService";
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import MetricsService from '../MetricsService';
|
||||
|
||||
describe('MetricsService', () => {
|
||||
it("should create a metrics service", () => {
|
||||
it('should create a metrics service', () => {
|
||||
const metricsService = MetricsService.create();
|
||||
expect(metricsService).toBeTruthy();
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
@@ -1,34 +1,38 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getSnapshot, Instance } from 'mobx-state-tree';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import TransactionService from '../TransactionService.ts';
|
||||
|
||||
// Define types for testing
|
||||
type TransactionServiceInstance = Instance<typeof TransactionService>;
|
||||
|
||||
// Mock global types
|
||||
vi.stubGlobal('Response', class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: any;
|
||||
vi.stubGlobal(
|
||||
'Response',
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
body: any;
|
||||
|
||||
constructor(body?: any, init?: ResponseInit) {
|
||||
this.body = body;
|
||||
this.status = init?.status || 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
constructor(body?: any, init?: ResponseInit) {
|
||||
this.body = body;
|
||||
this.status = init?.status || 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
|
||||
clone() {
|
||||
return this;
|
||||
}
|
||||
clone() {
|
||||
return this;
|
||||
}
|
||||
|
||||
async text() {
|
||||
return this.body?.toString() || '';
|
||||
}
|
||||
async text() {
|
||||
return this.body?.toString() || '';
|
||||
}
|
||||
|
||||
async json() {
|
||||
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
|
||||
}
|
||||
});
|
||||
async json() {
|
||||
return typeof this.body === 'string' ? JSON.parse(this.body) : this.body;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe('TransactionService', () => {
|
||||
let transactionService: TransactionServiceInstance;
|
||||
@@ -83,8 +87,9 @@ describe('TransactionService', () => {
|
||||
|
||||
it('should throw an error for unknown actions', async () => {
|
||||
// Call routeAction with an invalid action
|
||||
await expect(transactionService.routeAction('UNKNOWN_ACTION', ['data']))
|
||||
.rejects.toThrow('No handler for action: UNKNOWN_ACTION');
|
||||
await expect(transactionService.routeAction('UNKNOWN_ACTION', ['data'])).rejects.toThrow(
|
||||
'No handler for action: UNKNOWN_ACTION',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,8 +101,8 @@ describe('TransactionService', () => {
|
||||
// Mock KV_STORAGE
|
||||
const mockEnv = {
|
||||
KV_STORAGE: {
|
||||
put: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
put: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
transactionService.setEnv(mockEnv);
|
||||
});
|
||||
@@ -108,31 +113,33 @@ describe('TransactionService', () => {
|
||||
'mock-address',
|
||||
'mock-private-key',
|
||||
'mock-public-key',
|
||||
'mock-phrase'
|
||||
'mock-phrase',
|
||||
]);
|
||||
|
||||
global.fetch.mockResolvedValue({
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse)
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse),
|
||||
});
|
||||
|
||||
// Call the method with test data
|
||||
const result = await transactionService.handlePrepareTransaction(['donor123', 'bitcoin', '0.01']);
|
||||
const result = await transactionService.handlePrepareTransaction([
|
||||
'donor123',
|
||||
'bitcoin',
|
||||
'0.01',
|
||||
]);
|
||||
|
||||
// Verify fetch was called with the correct URL
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://wallets.seemueller.io/api/btc/create'
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://wallets.seemueller.io/api/btc/create');
|
||||
|
||||
// Verify KV_STORAGE.put was called with the correct data
|
||||
expect(transactionService.env.KV_STORAGE.put).toHaveBeenCalledWith(
|
||||
'transactions::prepared::mock-uuid',
|
||||
expect.stringContaining('mock-address')
|
||||
expect.stringContaining('mock-address'),
|
||||
);
|
||||
|
||||
// Verify the returned data
|
||||
expect(result).toEqual({
|
||||
depositAddress: 'mock-address',
|
||||
txKey: 'mock-uuid'
|
||||
txKey: 'mock-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,29 +149,25 @@ describe('TransactionService', () => {
|
||||
'mock-address',
|
||||
'mock-private-key',
|
||||
'mock-public-key',
|
||||
'mock-phrase'
|
||||
'mock-phrase',
|
||||
]);
|
||||
|
||||
global.fetch.mockResolvedValue({
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse)
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse),
|
||||
});
|
||||
|
||||
// Test with ethereum
|
||||
await transactionService.handlePrepareTransaction(['donor123', 'ethereum', '0.01']);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://wallets.seemueller.io/api/eth/create'
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://wallets.seemueller.io/api/eth/create');
|
||||
|
||||
// Reset mock and test with dogecoin
|
||||
vi.resetAllMocks();
|
||||
global.fetch.mockResolvedValue({
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse)
|
||||
text: vi.fn().mockResolvedValue(mockWalletResponse),
|
||||
});
|
||||
|
||||
await transactionService.handlePrepareTransaction(['donor123', 'dogecoin', '0.01']);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://wallets.seemueller.io/api/doge/create'
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://wallets.seemueller.io/api/doge/create');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,17 +180,18 @@ describe('TransactionService', () => {
|
||||
it('should process a valid transaction request', async () => {
|
||||
// Create a mock request
|
||||
const mockRequest = {
|
||||
text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01')
|
||||
text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01'),
|
||||
};
|
||||
|
||||
// Call the method
|
||||
const response = await transactionService.handleTransact(mockRequest);
|
||||
|
||||
// Verify routeAction was called with the correct parameters
|
||||
expect(transactionService.routeAction).toHaveBeenCalledWith(
|
||||
'PREPARE_TX',
|
||||
['donor123', 'bitcoin', '0.01']
|
||||
);
|
||||
expect(transactionService.routeAction).toHaveBeenCalledWith('PREPARE_TX', [
|
||||
'donor123',
|
||||
'bitcoin',
|
||||
'0.01',
|
||||
]);
|
||||
|
||||
// Verify the response
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
@@ -200,7 +204,7 @@ describe('TransactionService', () => {
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Create a mock request
|
||||
const mockRequest = {
|
||||
text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01')
|
||||
text: vi.fn().mockResolvedValue('PREPARE_TX,donor123,bitcoin,0.01'),
|
||||
};
|
||||
|
||||
// Make routeAction throw an error
|
||||
|
Reference in New Issue
Block a user