Skip to content

Commit 8d8f37d

Browse files
Add chat validation schemas and typed error handling
1 parent 973fbc3 commit 8d8f37d

File tree

1 file changed

+186
-0
lines changed

1 file changed

+186
-0
lines changed

app/lib/api/chat-validation.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { z } from 'zod';
2+
import type { FileMap } from '~/lib/.server/llm/constants';
3+
import type { DesignScheme } from '~/types/design-scheme';
4+
import type { Messages } from '~/lib/.server/llm/stream-text';
5+
6+
const messageSchema = z
7+
.object({
8+
id: z.string(),
9+
role: z.enum(['user', 'assistant', 'system', 'data']),
10+
content: z.string(),
11+
createdAt: z.any().optional(),
12+
})
13+
.passthrough();
14+
15+
const supabaseConfigSchema = z.object({
16+
isConnected: z.boolean(),
17+
hasSelectedProject: z.boolean(),
18+
credentials: z
19+
.object({
20+
anonKey: z.string().optional(),
21+
supabaseUrl: z.string().optional(),
22+
})
23+
.optional(),
24+
});
25+
26+
const chatRequestSchema = z.object({
27+
messages: z.array(messageSchema).min(1, 'At least one message is required'),
28+
files: z.record(z.string(), z.any()).default({}),
29+
promptId: z.string().optional(),
30+
contextOptimization: z.boolean().default(false),
31+
designScheme: z.any().optional(),
32+
supabase: supabaseConfigSchema.optional(),
33+
enableMCPTools: z.boolean().default(false),
34+
});
35+
36+
export interface ValidatedChatRequest {
37+
messages: Messages;
38+
files: FileMap;
39+
promptId?: string;
40+
contextOptimization: boolean;
41+
designScheme?: DesignScheme;
42+
supabase?: {
43+
isConnected: boolean;
44+
hasSelectedProject: boolean;
45+
credentials?: {
46+
anonKey?: string;
47+
supabaseUrl?: string;
48+
};
49+
};
50+
enableMCPTools: boolean;
51+
}
52+
53+
export function validateChatRequest(data: unknown): ValidatedChatRequest {
54+
const parsed = chatRequestSchema.parse(data);
55+
return parsed as ValidatedChatRequest;
56+
}
57+
58+
export type ChatErrorType =
59+
| 'validation_error'
60+
| 'auth_error'
61+
| 'rate_limit'
62+
| 'model_not_found'
63+
| 'context_too_large'
64+
| 'mcp_tool_error'
65+
| 'timeout'
66+
| 'stream_error'
67+
| 'provider_error'
68+
| 'unknown';
69+
70+
export interface ChatError {
71+
type: ChatErrorType;
72+
message: string;
73+
details?: Record<string, unknown>;
74+
retryable: boolean;
75+
}
76+
77+
export function createChatError(type: ChatErrorType, message: string, details?: Record<string, unknown>): ChatError {
78+
const retryableTypes: ChatErrorType[] = ['rate_limit', 'timeout', 'stream_error'];
79+
return {
80+
type,
81+
message,
82+
details,
83+
retryable: retryableTypes.includes(type),
84+
};
85+
}
86+
87+
export function formatValidationErrors(error: z.ZodError): string {
88+
return error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
89+
}
90+
91+
export function categorizeError(error: unknown): ChatError {
92+
if (error instanceof z.ZodError) {
93+
return createChatError('validation_error', formatValidationErrors(error), {
94+
errors: error.errors,
95+
});
96+
}
97+
98+
if (error instanceof Error) {
99+
const message = error.message.toLowerCase();
100+
101+
if (message.includes('api key') || message.includes('unauthorized') || message.includes('authentication')) {
102+
return createChatError('auth_error', 'Invalid or missing API key', {
103+
originalMessage: error.message,
104+
});
105+
}
106+
107+
if (message.includes('rate limit') || message.includes('too many requests') || message.includes('429')) {
108+
return createChatError('rate_limit', 'Rate limit exceeded. Please try again later.', {
109+
originalMessage: error.message,
110+
});
111+
}
112+
113+
if (message.includes('model') && (message.includes('not found') || message.includes('does not exist'))) {
114+
return createChatError('model_not_found', 'The requested model is not available', {
115+
originalMessage: error.message,
116+
});
117+
}
118+
119+
if (message.includes('context') && (message.includes('too large') || message.includes('token'))) {
120+
return createChatError('context_too_large', 'The context is too large for this model', {
121+
originalMessage: error.message,
122+
});
123+
}
124+
125+
if (message.includes('timeout') || message.includes('timed out')) {
126+
return createChatError('timeout', 'Request timed out. Please try again.', {
127+
originalMessage: error.message,
128+
});
129+
}
130+
131+
if (message.includes('mcp') || message.includes('tool')) {
132+
return createChatError('mcp_tool_error', 'MCP tool execution failed', {
133+
originalMessage: error.message,
134+
});
135+
}
136+
137+
return createChatError('provider_error', error.message);
138+
}
139+
140+
return createChatError('unknown', 'An unexpected error occurred');
141+
}
142+
143+
export function getHttpStatusForError(error: ChatError): number {
144+
switch (error.type) {
145+
case 'validation_error':
146+
return 400;
147+
case 'auth_error':
148+
return 401;
149+
case 'rate_limit':
150+
return 429;
151+
case 'model_not_found':
152+
return 404;
153+
case 'context_too_large':
154+
return 413;
155+
case 'timeout':
156+
return 504;
157+
default:
158+
return 500;
159+
}
160+
}
161+
162+
export const CHAT_TIMEOUT_MS = 5 * 60 * 1000;
163+
164+
export async function withTimeout<T>(
165+
promise: Promise<T>,
166+
timeoutMs: number = CHAT_TIMEOUT_MS,
167+
operationName: string = 'Operation',
168+
): Promise<T> {
169+
let timeoutId: ReturnType<typeof setTimeout>;
170+
171+
const timeoutPromise = new Promise<never>((_, reject) => {
172+
timeoutId = setTimeout(() => {
173+
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
174+
}, timeoutMs);
175+
});
176+
177+
try {
178+
const result = await Promise.race([promise, timeoutPromise]);
179+
clearTimeout(timeoutId!);
180+
181+
return result;
182+
} catch (error) {
183+
clearTimeout(timeoutId!);
184+
throw error;
185+
}
186+
}

0 commit comments

Comments
 (0)