@studious-lms/server 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.coderabbit.yaml +4 -1
- package/.env.example +6 -0
- package/.env.test.example +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -76
- package/dist/index.js.map +1 -1
- package/dist/lib/config/cors.d.ts +16 -0
- package/dist/lib/config/cors.d.ts.map +1 -0
- package/dist/lib/config/cors.js +75 -0
- package/dist/lib/config/cors.js.map +1 -0
- package/dist/lib/config/env.d.ts +14 -0
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +9 -2
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/fileUpload.d.ts +10 -2
- package/dist/lib/fileUpload.d.ts.map +1 -1
- package/dist/lib/fileUpload.js +38 -6
- package/dist/lib/fileUpload.js.map +1 -1
- package/dist/lib/prisma.d.ts +14 -2
- package/dist/lib/prisma.d.ts.map +1 -1
- package/dist/lib/prisma.js +27 -8
- package/dist/lib/prisma.js.map +1 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +18 -5
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/security.d.ts.map +1 -1
- package/dist/middleware/security.js +3 -3
- package/dist/middleware/security.js.map +1 -1
- package/dist/models/agenda.d.ts +16 -16
- package/dist/models/announcement.d.ts +59 -23
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +363 -276
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +63 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/auth.d.ts +115 -26
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/auth.js +53 -30
- package/dist/models/auth.js.map +1 -1
- package/dist/models/class.d.ts +115 -65
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +16 -4
- package/dist/models/class.js.map +1 -1
- package/dist/models/comment.d.ts +52 -16
- package/dist/models/comment.d.ts.map +1 -1
- package/dist/models/conversation.d.ts +46 -16
- package/dist/models/conversation.d.ts.map +1 -1
- package/dist/models/event.d.ts +107 -53
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/file.d.ts +213 -165
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +161 -77
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +73 -31
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketing.d.ts +25 -7
- package/dist/models/marketing.d.ts.map +1 -1
- package/dist/models/message.d.ts +31 -13
- package/dist/models/message.d.ts.map +1 -1
- package/dist/models/newtonChat.d.ts +34 -10
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/notification.d.ts +29 -10
- package/dist/models/notification.d.ts.map +1 -1
- package/dist/models/notification.js +13 -9
- package/dist/models/notification.js.map +1 -1
- package/dist/models/section.d.ts +71 -23
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/user.d.ts +27 -9
- package/dist/models/user.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +606 -106
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +168 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/pipelines/aiLabChat.d.ts +22 -2
- package/dist/pipelines/aiLabChat.d.ts.map +1 -1
- package/dist/pipelines/aiLabChat.js +133 -95
- package/dist/pipelines/aiLabChat.js.map +1 -1
- package/dist/pipelines/aiLabChatContract.d.ts +25 -25
- package/dist/pipelines/aiNewtonChat.d.ts.map +1 -1
- package/dist/pipelines/aiNewtonChat.js +25 -14
- package/dist/pipelines/aiNewtonChat.js.map +1 -1
- package/dist/pipelines/gradeWorksheet.d.ts +8 -8
- package/dist/pipelines/gradeWorksheet.d.ts.map +1 -1
- package/dist/pipelines/gradeWorksheet.js +12 -5
- package/dist/pipelines/gradeWorksheet.js.map +1 -1
- package/dist/pipelines/labChatPrompt.d.ts +27 -0
- package/dist/pipelines/labChatPrompt.d.ts.map +1 -1
- package/dist/pipelines/labChatPrompt.js +143 -69
- package/dist/pipelines/labChatPrompt.js.map +1 -1
- package/dist/routers/_app.d.ts +1579 -1257
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +16 -16
- package/dist/routers/announcement.d.ts +19 -19
- package/dist/routers/assignment.d.ts +307 -291
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +3 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +7 -7
- package/dist/routers/auth.d.ts +1 -1
- package/dist/routers/class.d.ts +79 -72
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +3 -3
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/comment.d.ts +6 -6
- package/dist/routers/conversation.d.ts +11 -11
- package/dist/routers/event.d.ts +35 -35
- package/dist/routers/file.d.ts +12 -12
- package/dist/routers/folder.d.ts +54 -54
- package/dist/routers/labChat.d.ts +18 -12
- package/dist/routers/labChat.d.ts.map +1 -1
- package/dist/routers/labChat.js +5 -3
- package/dist/routers/labChat.js.map +1 -1
- package/dist/routers/marketing.d.ts +2 -2
- package/dist/routers/message.d.ts +33 -2
- package/dist/routers/message.d.ts.map +1 -1
- package/dist/routers/message.js +27 -4
- package/dist/routers/message.js.map +1 -1
- package/dist/routers/newtonChat.d.ts +7 -1
- package/dist/routers/newtonChat.d.ts.map +1 -1
- package/dist/routers/newtonChat.js +5 -4
- package/dist/routers/newtonChat.js.map +1 -1
- package/dist/routers/notifications.d.ts +13 -6
- package/dist/routers/notifications.d.ts.map +1 -1
- package/dist/routers/notifications.js +4 -4
- package/dist/routers/notifications.js.map +1 -1
- package/dist/routers/section.d.ts +7 -7
- package/dist/routers/studentProgress.d.ts +86 -0
- package/dist/routers/studentProgress.d.ts.map +1 -1
- package/dist/routers/studentProgress.js +14 -4
- package/dist/routers/studentProgress.js.map +1 -1
- package/dist/routers/user.d.ts +1 -1
- package/dist/routers/worksheet.d.ts +64 -62
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +18 -16
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/seedDatabase.d.ts +1 -1
- package/dist/services/agenda.d.ts +16 -16
- package/dist/services/announcement.d.ts +8 -8
- package/dist/services/assignment.d.ts +299 -283
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +24 -5
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +7 -7
- package/dist/services/auth.d.ts +2 -2
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +19 -12
- package/dist/services/auth.js.map +1 -1
- package/dist/services/class.d.ts +74 -68
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +6 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/comment.d.ts +6 -6
- package/dist/services/conversation.d.ts +11 -11
- package/dist/services/event.d.ts +31 -31
- package/dist/services/file.d.ts +12 -12
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/file.js +7 -2
- package/dist/services/file.js.map +1 -1
- package/dist/services/folder.d.ts +52 -52
- package/dist/services/labChat.d.ts +18 -12
- package/dist/services/labChat.d.ts.map +1 -1
- package/dist/services/labChat.js +30 -18
- package/dist/services/labChat.js.map +1 -1
- package/dist/services/marketing.d.ts +2 -2
- package/dist/services/message.d.ts +30 -0
- package/dist/services/message.d.ts.map +1 -1
- package/dist/services/message.js +97 -21
- package/dist/services/message.js.map +1 -1
- package/dist/services/newtonChat.d.ts +6 -1
- package/dist/services/newtonChat.d.ts.map +1 -1
- package/dist/services/newtonChat.js +36 -18
- package/dist/services/newtonChat.js.map +1 -1
- package/dist/services/notification.d.ts +16 -9
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +24 -9
- package/dist/services/notification.js.map +1 -1
- package/dist/services/section.d.ts +6 -6
- package/dist/services/studentProgress.d.ts +75 -0
- package/dist/services/studentProgress.d.ts.map +1 -1
- package/dist/services/studentProgress.js +300 -107
- package/dist/services/studentProgress.js.map +1 -1
- package/dist/services/worksheet.d.ts +64 -64
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +138 -54
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +11 -17
- package/dist/trpc.js.map +1 -1
- package/dist/utils/inference.d.ts +9 -12
- package/dist/utils/inference.d.ts.map +1 -1
- package/dist/utils/inference.js +135 -95
- package/dist/utils/inference.js.map +1 -1
- package/package.json +1 -1
- package/prisma/migrations/20260410124000_add_submission_recommendation_state/migration.sql +14 -0
- package/prisma/migrations/20260415103500_add_session_type/migration.sql +33 -0
- package/prisma/migrations/20260415133000_add_ai_usage_logs/migration.sql +19 -0
- package/prisma/schema.prisma +41 -1
- package/scripts/test-pre-push.ts +6 -2
- package/src/index.ts +119 -79
- package/src/lib/config/cors.ts +96 -0
- package/src/lib/config/env.ts +12 -1
- package/src/lib/fileUpload.ts +55 -5
- package/src/lib/prisma.ts +25 -6
- package/src/middleware/auth.ts +19 -4
- package/src/middleware/security.ts +1 -1
- package/src/models/auth.ts +59 -29
- package/src/models/class.ts +15 -4
- package/src/models/notification.ts +13 -7
- package/src/models/worksheet.ts +202 -0
- package/src/pipelines/aiLabChat.ts +183 -104
- package/src/pipelines/aiNewtonChat.ts +24 -14
- package/src/pipelines/gradeWorksheet.ts +10 -2
- package/src/pipelines/labChatPrompt.ts +196 -68
- package/src/routers/assignment.ts +1 -0
- package/src/routers/class.ts +2 -2
- package/src/routers/labChat.ts +3 -1
- package/src/routers/message.ts +38 -1
- package/src/routers/newtonChat.ts +4 -2
- package/src/routers/notifications.ts +2 -2
- package/src/routers/studentProgress.ts +25 -1
- package/src/routers/worksheet.ts +31 -19
- package/src/services/assignment.ts +30 -2
- package/src/services/auth.ts +26 -12
- package/src/services/class.ts +4 -1
- package/src/services/file.ts +6 -0
- package/src/services/labChat.ts +42 -18
- package/src/services/message.ts +163 -18
- package/src/services/newtonChat.ts +49 -18
- package/src/services/notification.ts +27 -8
- package/src/services/studentProgress.ts +425 -121
- package/src/services/worksheet.ts +191 -51
- package/src/trpc.ts +12 -18
- package/src/utils/inference.ts +205 -112
- package/tests/globalSetup.ts +13 -4
- package/tests/lib/cors.test.ts +103 -0
- package/tests/pipelines/aiLabChat.test.ts +64 -84
- package/tests/routers/auth.test.ts +120 -17
- package/tests/routers/class.test.ts +7 -1
- package/tests/routers/message.test.ts +236 -9
- package/tests/routers/newtonChat.test.ts +81 -0
- package/tests/routers/notification.test.ts +12 -0
- package/tests/routers/studentProgress.test.ts +2 -31
- package/tests/routers/worksheet.test.ts +194 -0
- package/tests/setup.ts +22 -5
- package/tests/utils/aiLabChatPrompt.test.ts +114 -6
- package/tests/utils/inference.test.ts +218 -0
- package/tests/utils/studentProgress.test.ts +361 -0
- package/vitest.unit.config.ts +1 -0
package/src/utils/inference.ts
CHANGED
|
@@ -7,10 +7,9 @@ import { prisma } from '../lib/prisma.js';
|
|
|
7
7
|
import { chatChannel, pusher } from '../lib/pusher.js';
|
|
8
8
|
import { ensureAIUserExists, getAIUserId } from './aiUser.js';
|
|
9
9
|
import { env } from '../lib/config/env.js';
|
|
10
|
+
import { Prisma } from '@prisma/client';
|
|
10
11
|
import { ZodSchema } from 'zod';
|
|
11
|
-
import { zodTextFormat } from
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { zodTextFormat } from 'openai/helpers/zod';
|
|
14
13
|
|
|
15
14
|
export const inferenceClient = new OpenAI({
|
|
16
15
|
apiKey: env.INFERENCE_API_KEY,
|
|
@@ -19,6 +18,38 @@ export const inferenceClient = new OpenAI({
|
|
|
19
18
|
|
|
20
19
|
export const openAIClient = new OpenAI();
|
|
21
20
|
|
|
21
|
+
const DEFAULT_INFERENCE_MODEL = 'gpt-5-nano';
|
|
22
|
+
|
|
23
|
+
type InferenceMessage = {
|
|
24
|
+
role: 'user' | 'assistant' | 'system';
|
|
25
|
+
content: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface InferenceOptions {
|
|
29
|
+
reason?: string;
|
|
30
|
+
metadata?: Prisma.InputJsonValue;
|
|
31
|
+
model?: string;
|
|
32
|
+
temperature?: number;
|
|
33
|
+
maxTokens?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InferenceUsage {
|
|
37
|
+
promptTokens?: number;
|
|
38
|
+
completionTokens?: number;
|
|
39
|
+
totalTokens?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface InferenceAuditLog {
|
|
43
|
+
model: string;
|
|
44
|
+
operation: 'chat.completions' | 'responses.parse';
|
|
45
|
+
success: boolean;
|
|
46
|
+
estimatedInputTokens: number;
|
|
47
|
+
reason: string;
|
|
48
|
+
metadata?: Prisma.InputJsonValue;
|
|
49
|
+
usage?: InferenceUsage;
|
|
50
|
+
error?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
22
53
|
// Types for lab chat context
|
|
23
54
|
export interface LabChatContext {
|
|
24
55
|
subject: string;
|
|
@@ -39,6 +70,116 @@ export interface InferenceResponse {
|
|
|
39
70
|
finishReason: string;
|
|
40
71
|
}
|
|
41
72
|
|
|
73
|
+
function normalizeInferenceInput(
|
|
74
|
+
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
75
|
+
): InferenceMessage[] {
|
|
76
|
+
if (typeof content === 'string') {
|
|
77
|
+
return [{ role: 'user', content }];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return content.map((message) => ({
|
|
81
|
+
role: message.role as InferenceMessage['role'],
|
|
82
|
+
content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getInferenceReason(options?: InferenceOptions) {
|
|
87
|
+
return options?.reason?.trim() || 'unspecified';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function serializeInferenceError(error: unknown) {
|
|
91
|
+
if (error instanceof Error) {
|
|
92
|
+
return error.message;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof error === 'string') {
|
|
96
|
+
return error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return JSON.stringify(error);
|
|
101
|
+
} catch {
|
|
102
|
+
return 'Unknown inference error';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function logInferenceUsage({
|
|
107
|
+
model,
|
|
108
|
+
operation,
|
|
109
|
+
success,
|
|
110
|
+
estimatedInputTokens,
|
|
111
|
+
reason,
|
|
112
|
+
metadata,
|
|
113
|
+
usage,
|
|
114
|
+
error,
|
|
115
|
+
}: InferenceAuditLog) {
|
|
116
|
+
const context = {
|
|
117
|
+
reason,
|
|
118
|
+
model,
|
|
119
|
+
operation,
|
|
120
|
+
success,
|
|
121
|
+
estimatedInputTokens,
|
|
122
|
+
promptTokens: usage?.promptTokens,
|
|
123
|
+
completionTokens: usage?.completionTokens,
|
|
124
|
+
totalTokens: usage?.totalTokens,
|
|
125
|
+
metadata,
|
|
126
|
+
...(error ? { error: serializeInferenceError(error) } : {}),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await prisma.aiUsageLog.create({
|
|
131
|
+
data: {
|
|
132
|
+
reason,
|
|
133
|
+
model,
|
|
134
|
+
operation,
|
|
135
|
+
success,
|
|
136
|
+
estimatedInputTokens,
|
|
137
|
+
promptTokens: usage?.promptTokens,
|
|
138
|
+
completionTokens: usage?.completionTokens,
|
|
139
|
+
totalTokens: usage?.totalTokens,
|
|
140
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
141
|
+
...(error ? { error: serializeInferenceError(error) } : {}),
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
} catch (loggingError) {
|
|
145
|
+
logger.error('Failed to persist AI usage log', {
|
|
146
|
+
...context,
|
|
147
|
+
loggingError: serializeInferenceError(loggingError),
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
logger.info('AI usage logged', context);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getChatCompletionUsage(completion: {
|
|
156
|
+
usage?: {
|
|
157
|
+
prompt_tokens?: number;
|
|
158
|
+
completion_tokens?: number;
|
|
159
|
+
total_tokens?: number;
|
|
160
|
+
};
|
|
161
|
+
}): InferenceUsage {
|
|
162
|
+
return {
|
|
163
|
+
promptTokens: completion.usage?.prompt_tokens,
|
|
164
|
+
completionTokens: completion.usage?.completion_tokens,
|
|
165
|
+
totalTokens: completion.usage?.total_tokens,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getResponseUsage(completion: {
|
|
170
|
+
usage?: {
|
|
171
|
+
input_tokens?: number;
|
|
172
|
+
output_tokens?: number;
|
|
173
|
+
total_tokens?: number;
|
|
174
|
+
};
|
|
175
|
+
}): InferenceUsage {
|
|
176
|
+
return {
|
|
177
|
+
promptTokens: completion.usage?.input_tokens,
|
|
178
|
+
completionTokens: completion.usage?.output_tokens,
|
|
179
|
+
totalTokens: completion.usage?.total_tokens,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
42
183
|
/**
|
|
43
184
|
* Centralized function to send AI messages to conversations
|
|
44
185
|
* Handles database storage and Pusher broadcasting
|
|
@@ -65,10 +206,8 @@ export async function sendAIMessage(
|
|
|
65
206
|
createdAt: Date;
|
|
66
207
|
meta?: Record<string, any>;
|
|
67
208
|
}> {
|
|
68
|
-
// Ensure AI user exists
|
|
69
209
|
await ensureAIUserExists();
|
|
70
210
|
|
|
71
|
-
// Create message in database
|
|
72
211
|
const aiMessage = await prisma.message.create({
|
|
73
212
|
data: {
|
|
74
213
|
content,
|
|
@@ -94,17 +233,15 @@ export async function sendAIMessage(
|
|
|
94
233
|
contentLength: content.length,
|
|
95
234
|
});
|
|
96
235
|
|
|
97
|
-
// Prepare sender info
|
|
98
236
|
const senderInfo = {
|
|
99
237
|
id: getAIUserId(),
|
|
100
238
|
username: 'Newton_AI',
|
|
101
239
|
profile: {
|
|
102
|
-
displayName:
|
|
240
|
+
displayName: 'Newton AI',
|
|
103
241
|
profilePicture: options.customSender?.profilePicture || null,
|
|
104
242
|
},
|
|
105
243
|
};
|
|
106
244
|
|
|
107
|
-
// Broadcast via Pusher
|
|
108
245
|
try {
|
|
109
246
|
await pusher.trigger(chatChannel(conversationId), 'new-message', {
|
|
110
247
|
id: aiMessage.id,
|
|
@@ -115,7 +252,7 @@ export async function sendAIMessage(
|
|
|
115
252
|
sender: senderInfo,
|
|
116
253
|
mentionedUserIds: [],
|
|
117
254
|
meta: aiMessage.meta,
|
|
118
|
-
attachments: aiMessage.attachments.map(attachment => ({
|
|
255
|
+
attachments: aiMessage.attachments.map((attachment) => ({
|
|
119
256
|
id: attachment.id,
|
|
120
257
|
attachmentId: attachment.id,
|
|
121
258
|
name: attachment.name,
|
|
@@ -140,132 +277,89 @@ export async function sendAIMessage(
|
|
|
140
277
|
|
|
141
278
|
export async function inference<T>(
|
|
142
279
|
content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
|
|
143
|
-
format?: ZodSchema
|
|
280
|
+
format?: ZodSchema,
|
|
281
|
+
options?: InferenceOptions,
|
|
144
282
|
): Promise<T> {
|
|
145
|
-
|
|
283
|
+
const input = normalizeInferenceInput(content);
|
|
284
|
+
const reason = getInferenceReason(options);
|
|
285
|
+
const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
|
|
286
|
+
const estimatedInputTokens = input.reduce(
|
|
287
|
+
(total, message) => total + estimateTokenCount(message.content),
|
|
288
|
+
0,
|
|
289
|
+
);
|
|
290
|
+
const operation = format ? 'responses.parse' : 'chat.completions';
|
|
146
291
|
|
|
292
|
+
try {
|
|
147
293
|
if (!format) {
|
|
148
294
|
const completion = await openAIClient.chat.completions.create({
|
|
149
|
-
model
|
|
150
|
-
messages:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
295
|
+
model,
|
|
296
|
+
messages: input,
|
|
297
|
+
...(options?.temperature !== undefined
|
|
298
|
+
? { temperature: options.temperature }
|
|
299
|
+
: {}),
|
|
300
|
+
...(options?.maxTokens !== undefined
|
|
301
|
+
? { max_tokens: options.maxTokens }
|
|
302
|
+
: {}),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await logInferenceUsage({
|
|
306
|
+
model,
|
|
307
|
+
operation,
|
|
308
|
+
success: true,
|
|
309
|
+
estimatedInputTokens,
|
|
310
|
+
reason,
|
|
311
|
+
metadata: options?.metadata,
|
|
312
|
+
usage: getChatCompletionUsage(completion),
|
|
156
313
|
});
|
|
157
314
|
|
|
158
315
|
return completion.choices[0]?.message?.content as T;
|
|
159
316
|
}
|
|
160
317
|
|
|
161
318
|
const completion = await openAIClient.responses.parse({
|
|
162
|
-
model
|
|
163
|
-
input
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
},
|
|
168
|
-
] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
169
|
-
...(format ? { text: {
|
|
170
|
-
format: zodTextFormat(format, "newton_response_format"),
|
|
171
|
-
},
|
|
172
|
-
} : {}),
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
console.log({
|
|
176
|
-
model: 'gpt-5-nano',
|
|
177
|
-
input: typeof content === 'string' ? [
|
|
178
|
-
{
|
|
179
|
-
role: 'user',
|
|
180
|
-
content: content,
|
|
181
|
-
},
|
|
182
|
-
] : content as Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
|
|
183
|
-
...(format ? { text: {
|
|
184
|
-
format: zodTextFormat(format, "newton_response_format"),
|
|
185
|
-
},
|
|
186
|
-
} : {}),
|
|
319
|
+
model,
|
|
320
|
+
input,
|
|
321
|
+
text: {
|
|
322
|
+
format: zodTextFormat(format, 'newton_response_format'),
|
|
323
|
+
},
|
|
187
324
|
});
|
|
188
325
|
|
|
189
|
-
|
|
190
326
|
if (!completion) {
|
|
191
327
|
throw new Error('No response generated from inference API');
|
|
192
328
|
}
|
|
193
329
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
330
|
+
await logInferenceUsage({
|
|
331
|
+
model,
|
|
332
|
+
operation,
|
|
333
|
+
success: true,
|
|
334
|
+
estimatedInputTokens,
|
|
335
|
+
reason,
|
|
336
|
+
metadata: options?.metadata,
|
|
337
|
+
usage: getResponseUsage(completion),
|
|
338
|
+
});
|
|
200
339
|
|
|
201
|
-
return completion.output_parsed;
|
|
340
|
+
return completion.output_parsed as T;
|
|
202
341
|
} catch (error) {
|
|
203
|
-
|
|
204
|
-
throw error;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Simple inference function for general use
|
|
210
|
-
*/
|
|
211
|
-
export async function generateInferenceResponse(
|
|
212
|
-
subject: string,
|
|
213
|
-
question: string,
|
|
214
|
-
options: {
|
|
215
|
-
model?: string;
|
|
216
|
-
maxTokens?: number;
|
|
217
|
-
} = {}
|
|
218
|
-
): Promise<InferenceResponse> {
|
|
219
|
-
const { model = 'gpt-5-nano', maxTokens = 500 } = options;
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
const completion = await openAIClient.chat.completions.create({
|
|
342
|
+
await logInferenceUsage({
|
|
223
343
|
model,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
role: 'user',
|
|
231
|
-
content: question,
|
|
232
|
-
},
|
|
233
|
-
],
|
|
234
|
-
max_tokens: maxTokens,
|
|
235
|
-
temperature: 0.5,
|
|
236
|
-
// Remove OpenAI-specific parameters for Cohere compatibility
|
|
344
|
+
operation,
|
|
345
|
+
success: false,
|
|
346
|
+
estimatedInputTokens,
|
|
347
|
+
reason,
|
|
348
|
+
metadata: options?.metadata,
|
|
349
|
+
error,
|
|
237
350
|
});
|
|
238
351
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
throw new Error('No response generated from inference API');
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
return {
|
|
246
|
-
content: response,
|
|
352
|
+
logger.error('Failed to generate inference response', {
|
|
353
|
+
error,
|
|
354
|
+
reason,
|
|
247
355
|
model,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
} catch (error) {
|
|
253
|
-
logger.error('Failed to generate inference response', { error, subject, question: question.substring(0, 50) + '...' });
|
|
356
|
+
operation,
|
|
357
|
+
metadata: options?.metadata,
|
|
358
|
+
});
|
|
254
359
|
throw error;
|
|
255
360
|
}
|
|
256
361
|
}
|
|
257
362
|
|
|
258
|
-
/**
|
|
259
|
-
* Validate inference configuration
|
|
260
|
-
*/
|
|
261
|
-
export function validateInferenceConfig(): boolean {
|
|
262
|
-
if (!env.INFERENCE_API_KEY) {
|
|
263
|
-
logger.error('Inference API key not configured for Cohere');
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
363
|
/**
|
|
270
364
|
* Get available inference models (for admin/config purposes)
|
|
271
365
|
*/
|
|
@@ -278,7 +372,7 @@ export async function getAvailableModels(): Promise<string[]> {
|
|
|
278
372
|
.sort();
|
|
279
373
|
} catch (error) {
|
|
280
374
|
logger.error('Failed to fetch inference models', { error });
|
|
281
|
-
return ['command-r-plus', 'command-r', 'command-light'];
|
|
375
|
+
return ['command-r-plus', 'command-r', 'command-light'];
|
|
282
376
|
}
|
|
283
377
|
}
|
|
284
378
|
|
|
@@ -286,6 +380,5 @@ export async function getAvailableModels(): Promise<string[]> {
|
|
|
286
380
|
* Estimate token count for a message (rough approximation)
|
|
287
381
|
*/
|
|
288
382
|
export function estimateTokenCount(text: string): number {
|
|
289
|
-
// Rough approximation: 1 token ≈ 4 characters for English text
|
|
290
383
|
return Math.ceil(text.length / 4);
|
|
291
|
-
}
|
|
384
|
+
}
|
package/tests/globalSetup.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { appRouter } from '../src/routers/_app';
|
|
|
9
9
|
import { createTRPCContext } from '../src/trpc';
|
|
10
10
|
import { clearDatabase } from '../src/seedDatabase';
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
|
+
import { SessionType } from '@prisma/client';
|
|
12
13
|
|
|
13
14
|
async function runSetup() {
|
|
14
15
|
const ctx = await createTRPCContext({
|
|
@@ -40,9 +41,18 @@ async function runSetup() {
|
|
|
40
41
|
confirmPassword: 'password_is_1234',
|
|
41
42
|
});
|
|
42
43
|
|
|
43
|
-
const session1 = await prisma.session.findFirst({
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
const session1 = await prisma.session.findFirst({
|
|
45
|
+
where: { userId: user1.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
46
|
+
orderBy: { createdAt: 'desc' },
|
|
47
|
+
});
|
|
48
|
+
const session2 = await prisma.session.findFirst({
|
|
49
|
+
where: { userId: user2.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
50
|
+
orderBy: { createdAt: 'desc' },
|
|
51
|
+
});
|
|
52
|
+
const session3 = await prisma.session.findFirst({
|
|
53
|
+
where: { userId: user3.user.id, type: SessionType.EMAIL_VERIFICATION },
|
|
54
|
+
orderBy: { createdAt: 'desc' },
|
|
55
|
+
});
|
|
46
56
|
|
|
47
57
|
if (!session1 || !session2 || !session3) {
|
|
48
58
|
throw new Error('Failed to create sessions for test users');
|
|
@@ -59,4 +69,3 @@ export default async function setup(project: TestProject) {
|
|
|
59
69
|
project.onTestsRerun(runSetup);
|
|
60
70
|
await runSetup();
|
|
61
71
|
}
|
|
62
|
-
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createCorsOriginMatcher,
|
|
4
|
+
isValidCorsOriginPatternList,
|
|
5
|
+
parseCorsOriginPatterns,
|
|
6
|
+
parseCorsOrigins,
|
|
7
|
+
} from '../../src/lib/config/cors.js';
|
|
8
|
+
|
|
9
|
+
describe('CORS configuration', () => {
|
|
10
|
+
test('parses comma-separated origins while trimming empty entries', () => {
|
|
11
|
+
expect(parseCorsOrigins(' https://one.example, ,https://two.example ')).toEqual([
|
|
12
|
+
'https://one.example',
|
|
13
|
+
'https://two.example',
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('normalizes configured origin URLs before matching', () => {
|
|
18
|
+
const { allowedOrigins, isAllowedOrigin } = createCorsOriginMatcher({
|
|
19
|
+
NODE_ENV: 'production',
|
|
20
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example/dashboard/',
|
|
21
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example/some/path/, https://branch.example/',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(allowedOrigins).toContain('https://app.example');
|
|
25
|
+
expect(allowedOrigins).toContain('https://preview.example');
|
|
26
|
+
expect(allowedOrigins).toContain('https://branch.example');
|
|
27
|
+
expect(isAllowedOrigin('https://preview.example/')).toBe(true);
|
|
28
|
+
expect(isAllowedOrigin('https://branch.example/path')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('allows exact configured origins', () => {
|
|
32
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
33
|
+
NODE_ENV: 'production',
|
|
34
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
35
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example,https://branch.example',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(isAllowedOrigin('https://www.studious.sh')).toBe(true);
|
|
39
|
+
expect(isAllowedOrigin('https://app.example')).toBe(true);
|
|
40
|
+
expect(isAllowedOrigin('https://preview.example')).toBe(true);
|
|
41
|
+
expect(isAllowedOrigin('https://branch.example')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('does not allow dev subdomains in production unless configured', () => {
|
|
45
|
+
const defaultProductionMatcher = createCorsOriginMatcher({
|
|
46
|
+
NODE_ENV: 'production',
|
|
47
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
48
|
+
});
|
|
49
|
+
const configuredProductionMatcher = createCorsOriginMatcher({
|
|
50
|
+
NODE_ENV: 'production',
|
|
51
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
52
|
+
CORS_ALLOWED_ORIGINS: 'https://dev.studious.sh',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(defaultProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(false);
|
|
56
|
+
expect(defaultProductionMatcher.isAllowedOrigin('https://www.dev.studious.sh')).toBe(false);
|
|
57
|
+
expect(configuredProductionMatcher.isAllowedOrigin('https://dev.studious.sh')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('allows narrow configured origin patterns', () => {
|
|
61
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
62
|
+
NODE_ENV: 'production',
|
|
63
|
+
CORS_ALLOWED_ORIGIN_PATTERNS: '^https://studious-git-[a-z0-9-]+-studious-lms\\.vercel\\.app$',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(isAllowedOrigin('https://studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(true);
|
|
67
|
+
expect(isAllowedOrigin('https://malicious-studious-git-configure-vercel-cors-studious-lms.vercel.app')).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('rejects missing and unconfigured origins instead of using a fallback', () => {
|
|
71
|
+
const { isAllowedOrigin } = createCorsOriginMatcher({
|
|
72
|
+
NODE_ENV: 'production',
|
|
73
|
+
NEXT_PUBLIC_APP_URL: 'https://app.example',
|
|
74
|
+
CORS_ALLOWED_ORIGINS: 'https://preview.example',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(isAllowedOrigin()).toBe(false);
|
|
78
|
+
expect(isAllowedOrigin('https://evil.example')).toBe(false);
|
|
79
|
+
expect(isAllowedOrigin('http://localhost:3000')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('compiles configured regex patterns', () => {
|
|
83
|
+
expect(parseCorsOriginPatterns('^https://one\\.example$, ^https://two\\.example$').map((pattern) => pattern.source)).toEqual([
|
|
84
|
+
'^https:\\/\\/one\\.example$',
|
|
85
|
+
'^https:\\/\\/two\\.example$',
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('validates origin pattern syntax for env parsing', () => {
|
|
90
|
+
expect(isValidCorsOriginPatternList()).toBe(true);
|
|
91
|
+
expect(isValidCorsOriginPatternList('')).toBe(true);
|
|
92
|
+
expect(isValidCorsOriginPatternList('^https://one\\.example$')).toBe(true);
|
|
93
|
+
expect(isValidCorsOriginPatternList('(')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('rejects overly long origin patterns', () => {
|
|
97
|
+
expect(isValidCorsOriginPatternList(`^https://${'a'.repeat(250)}\\.example$`)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('rejects origin patterns with nested quantifiers', () => {
|
|
101
|
+
expect(isValidCorsOriginPatternList('^(a+)+$')).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|