@studious-lms/server 1.4.3 → 1.4.5

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.
Files changed (80) hide show
  1. package/.vscode/settings.json +3 -0
  2. package/dist/index.js +4 -18
  3. package/dist/index.js.map +1 -1
  4. package/dist/models/conversation.d.ts +22 -0
  5. package/dist/models/conversation.d.ts.map +1 -1
  6. package/dist/models/conversation.js +13 -2
  7. package/dist/models/conversation.js.map +1 -1
  8. package/dist/models/labChat.d.ts +3 -0
  9. package/dist/models/labChat.d.ts.map +1 -1
  10. package/dist/models/message.d.ts +2 -0
  11. package/dist/models/message.d.ts.map +1 -1
  12. package/dist/models/newtonChat.d.ts +1 -0
  13. package/dist/models/newtonChat.d.ts.map +1 -1
  14. package/dist/models/worksheet.d.ts +1 -0
  15. package/dist/models/worksheet.d.ts.map +1 -1
  16. package/dist/models/worksheet.js +2 -2
  17. package/dist/models/worksheet.js.map +1 -1
  18. package/dist/pipelines/pdfToWorksheet.d.ts +12 -0
  19. package/dist/pipelines/pdfToWorksheet.d.ts.map +1 -0
  20. package/dist/pipelines/pdfToWorksheet.js +155 -0
  21. package/dist/pipelines/pdfToWorksheet.js.map +1 -0
  22. package/dist/routers/_app.d.ts +252 -0
  23. package/dist/routers/_app.d.ts.map +1 -1
  24. package/dist/routers/_app.js +4 -2
  25. package/dist/routers/_app.js.map +1 -1
  26. package/dist/routers/conversation.d.ts +14 -0
  27. package/dist/routers/conversation.d.ts.map +1 -1
  28. package/dist/routers/conversation.js +6 -3
  29. package/dist/routers/conversation.js.map +1 -1
  30. package/dist/routers/labChat.d.ts +2 -0
  31. package/dist/routers/labChat.d.ts.map +1 -1
  32. package/dist/routers/search.d.ts +41 -0
  33. package/dist/routers/search.d.ts.map +1 -0
  34. package/dist/routers/search.js +15 -0
  35. package/dist/routers/search.js.map +1 -0
  36. package/dist/routers/worksheet.d.ts +71 -0
  37. package/dist/routers/worksheet.d.ts.map +1 -1
  38. package/dist/routers/worksheet.js +110 -3
  39. package/dist/routers/worksheet.js.map +1 -1
  40. package/dist/services/conversation.d.ts +8 -0
  41. package/dist/services/conversation.d.ts.map +1 -1
  42. package/dist/services/conversation.js +24 -6
  43. package/dist/services/conversation.js.map +1 -1
  44. package/dist/services/labChat.d.ts +2 -0
  45. package/dist/services/labChat.d.ts.map +1 -1
  46. package/dist/services/message.d.ts.map +1 -1
  47. package/dist/services/message.js +9 -2
  48. package/dist/services/message.js.map +1 -1
  49. package/dist/services/search.d.ts +21 -0
  50. package/dist/services/search.d.ts.map +1 -0
  51. package/dist/services/search.js +352 -0
  52. package/dist/services/search.js.map +1 -0
  53. package/dist/services/worksheet.d.ts +21 -0
  54. package/dist/services/worksheet.d.ts.map +1 -1
  55. package/dist/services/worksheet.js +50 -2
  56. package/dist/services/worksheet.js.map +1 -1
  57. package/dist/utils/inference.d.ts +2 -1
  58. package/dist/utils/inference.d.ts.map +1 -1
  59. package/dist/utils/inference.js +53 -5
  60. package/dist/utils/inference.js.map +1 -1
  61. package/package.json +1 -1
  62. package/prisma/migrations/20260417083000_add_conversation_member_hidden_at/migration.sql +2 -0
  63. package/prisma/schema.prisma +1 -0
  64. package/src/index.ts +4 -19
  65. package/src/models/conversation.ts +12 -0
  66. package/src/models/worksheet.ts +1 -0
  67. package/src/pipelines/pdfToWorksheet.ts +177 -0
  68. package/src/routers/_app.ts +2 -0
  69. package/src/routers/conversation.ts +7 -0
  70. package/src/routers/search.ts +14 -0
  71. package/src/routers/worksheet.ts +143 -0
  72. package/src/services/conversation.ts +27 -3
  73. package/src/services/message.ts +8 -0
  74. package/src/services/search.ts +397 -0
  75. package/src/services/worksheet.ts +61 -0
  76. package/src/utils/inference.ts +77 -7
  77. package/tests/pipelines/pdfToWorksheet.test.ts +176 -0
  78. package/tests/routers/conversation.test.ts +114 -2
  79. package/tests/routers/search.test.ts +109 -0
  80. package/tests/utils/inference.test.ts +37 -0
@@ -0,0 +1,397 @@
1
+ import { prisma } from "../lib/prisma.js";
2
+
3
+ type SearchScope =
4
+ | "class"
5
+ | "assignment"
6
+ | "event"
7
+ | "syllabus"
8
+ | "worksheet"
9
+ | "file"
10
+ | "announcement"
11
+ | "section";
12
+
13
+ type SearchResult = {
14
+ id: string;
15
+ type: SearchScope;
16
+ title: string;
17
+ subtitle: string;
18
+ classId: string;
19
+ className: string;
20
+ href: string;
21
+ matchedText?: string | null;
22
+ date?: Date | null;
23
+ };
24
+
25
+ const SEARCH_LIMIT = 12;
26
+
27
+ const contains = (query: string) => ({
28
+ contains: query,
29
+ mode: "insensitive" as const,
30
+ });
31
+
32
+ const includesToken = (query: string, ...tokens: string[]) =>
33
+ tokens.some((token) => token.includes(query.toLowerCase()));
34
+
35
+ const excerpt = (value: string | null | undefined, query: string) => {
36
+ if (!value) return null;
37
+ const trimmed = value.replace(/\s+/g, " ").trim();
38
+ if (!trimmed) return null;
39
+ if (!query) return trimmed.slice(0, 140);
40
+
41
+ const index = trimmed.toLowerCase().indexOf(query.toLowerCase());
42
+ if (index < 0) return trimmed.slice(0, 140);
43
+
44
+ const start = Math.max(0, index - 48);
45
+ const end = Math.min(trimmed.length, index + query.length + 92);
46
+ return `${start > 0 ? "..." : ""}${trimmed.slice(start, end)}${end < trimmed.length ? "..." : ""}`;
47
+ };
48
+
49
+ const trimResults = (results: SearchResult[], limit: number) =>
50
+ results
51
+ .sort((a, b) => {
52
+ const priority: Record<SearchScope, number> = {
53
+ class: 0,
54
+ assignment: 1,
55
+ event: 2,
56
+ syllabus: 3,
57
+ worksheet: 4,
58
+ file: 5,
59
+ announcement: 6,
60
+ section: 7,
61
+ };
62
+ const priorityDiff = priority[a.type] - priority[b.type];
63
+ if (priorityDiff !== 0) return priorityDiff;
64
+ return (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0);
65
+ })
66
+ .slice(0, limit);
67
+
68
+ export async function searchGlobalClasses(
69
+ userId: string,
70
+ input: { query?: string; limit?: number }
71
+ ) {
72
+ const rawQuery = input.query?.trim() ?? "";
73
+ const query = rawQuery.slice(0, 80);
74
+ const limit = Math.min(Math.max(input.limit ?? SEARCH_LIMIT, 1), 30);
75
+
76
+ const memberships = await prisma.class.findMany({
77
+ where: {
78
+ OR: [
79
+ { teachers: { some: { id: userId } } },
80
+ { students: { some: { id: userId } } },
81
+ ],
82
+ },
83
+ select: {
84
+ id: true,
85
+ name: true,
86
+ section: true,
87
+ subject: true,
88
+ syllabus: true,
89
+ teachers: { where: { id: userId }, select: { id: true } },
90
+ students: { where: { id: userId }, select: { id: true } },
91
+ },
92
+ });
93
+
94
+ const allClassIds = memberships.map((cls) => cls.id);
95
+ if (allClassIds.length === 0) {
96
+ return { query, results: [] as SearchResult[] };
97
+ }
98
+
99
+ const teacherClassIds = memberships
100
+ .filter((cls) => cls.teachers.length > 0)
101
+ .map((cls) => cls.id);
102
+ const studentClassIds = memberships
103
+ .filter((cls) => cls.students.length > 0 && cls.teachers.length === 0)
104
+ .map((cls) => cls.id);
105
+ const classById = new Map(memberships.map((cls) => [cls.id, cls]));
106
+
107
+ const queryFilters = query
108
+ ? {
109
+ class: [
110
+ { name: contains(query) },
111
+ { section: contains(query) },
112
+ { subject: contains(query) },
113
+ ],
114
+ assignment: [
115
+ { title: contains(query) },
116
+ { instructions: contains(query) },
117
+ ],
118
+ event: [
119
+ { name: contains(query) },
120
+ { location: contains(query) },
121
+ { remarks: contains(query) },
122
+ ],
123
+ worksheet: [{ name: contains(query) }],
124
+ file: [{ name: contains(query) }, { type: contains(query) }],
125
+ announcement: [{ remarks: contains(query) }],
126
+ section: [{ name: contains(query) }],
127
+ }
128
+ : null;
129
+
130
+ const assignmentAccess = [
131
+ ...(teacherClassIds.length > 0
132
+ ? [{ classId: { in: teacherClassIds } }]
133
+ : []),
134
+ ...(studentClassIds.length > 0
135
+ ? [
136
+ {
137
+ classId: { in: studentClassIds },
138
+ OR: [
139
+ { assignedTo: { some: { id: userId } } },
140
+ { assignedTo: { none: {} } },
141
+ ],
142
+ },
143
+ ]
144
+ : []),
145
+ ];
146
+
147
+ const [
148
+ classes,
149
+ assignments,
150
+ events,
151
+ worksheets,
152
+ files,
153
+ announcements,
154
+ sections,
155
+ ] = await Promise.all([
156
+ prisma.class.findMany({
157
+ where: {
158
+ id: { in: allClassIds },
159
+ ...(queryFilters ? { OR: queryFilters.class } : {}),
160
+ },
161
+ select: { id: true, name: true, section: true, subject: true },
162
+ take: limit,
163
+ }),
164
+ prisma.assignment.findMany({
165
+ where: {
166
+ template: false,
167
+ AND: [
168
+ ...(queryFilters ? [{ OR: queryFilters.assignment }] : []),
169
+ ...(assignmentAccess.length > 0 ? [{ OR: assignmentAccess }] : []),
170
+ ],
171
+ },
172
+ select: {
173
+ id: true,
174
+ title: true,
175
+ instructions: true,
176
+ dueDate: true,
177
+ modifiedAt: true,
178
+ classId: true,
179
+ class: { select: { name: true } },
180
+ },
181
+ orderBy: { dueDate: "asc" },
182
+ take: limit,
183
+ }),
184
+ prisma.event.findMany({
185
+ where: {
186
+ classId: { in: allClassIds },
187
+ ...(queryFilters ? { OR: queryFilters.event } : {}),
188
+ },
189
+ select: {
190
+ id: true,
191
+ name: true,
192
+ location: true,
193
+ remarks: true,
194
+ startTime: true,
195
+ classId: true,
196
+ class: { select: { name: true } },
197
+ },
198
+ orderBy: { startTime: "asc" },
199
+ take: limit,
200
+ }),
201
+ prisma.worksheet.findMany({
202
+ where: {
203
+ classId: { in: allClassIds },
204
+ ...(queryFilters && !includesToken(query, "worksheet", "worksheets")
205
+ ? { OR: queryFilters.worksheet }
206
+ : {}),
207
+ },
208
+ select: {
209
+ id: true,
210
+ name: true,
211
+ updatedAt: true,
212
+ classId: true,
213
+ class: { select: { name: true } },
214
+ },
215
+ orderBy: { updatedAt: "desc" },
216
+ take: limit,
217
+ }),
218
+ prisma.file.findMany({
219
+ where: {
220
+ ...(queryFilters ? { OR: queryFilters.file } : {}),
221
+ AND: [
222
+ {
223
+ OR: [
224
+ { folder: { classId: { in: allClassIds } } },
225
+ { classDraftId: { in: teacherClassIds } },
226
+ {
227
+ assignment: {
228
+ OR: assignmentAccess,
229
+ },
230
+ },
231
+ { announcement: { classId: { in: allClassIds } } },
232
+ ],
233
+ },
234
+ ],
235
+ },
236
+ select: {
237
+ id: true,
238
+ name: true,
239
+ type: true,
240
+ uploadedAt: true,
241
+ folder: { select: { classId: true, class: { select: { name: true } } } },
242
+ classDraft: { select: { id: true, name: true } },
243
+ assignment: { select: { classId: true, class: { select: { name: true } } } },
244
+ announcement: { select: { classId: true, class: { select: { name: true } } } },
245
+ },
246
+ orderBy: { uploadedAt: "desc" },
247
+ take: limit,
248
+ }),
249
+ prisma.announcement.findMany({
250
+ where: {
251
+ classId: { in: allClassIds },
252
+ ...(queryFilters ? { OR: queryFilters.announcement } : {}),
253
+ },
254
+ select: {
255
+ id: true,
256
+ remarks: true,
257
+ createdAt: true,
258
+ classId: true,
259
+ class: { select: { name: true } },
260
+ },
261
+ orderBy: { createdAt: "desc" },
262
+ take: limit,
263
+ }),
264
+ prisma.section.findMany({
265
+ where: {
266
+ classId: { in: allClassIds },
267
+ ...(queryFilters && !includesToken(query, "section", "sections", "assignment", "assignments")
268
+ ? { OR: queryFilters.section }
269
+ : {}),
270
+ },
271
+ select: {
272
+ id: true,
273
+ name: true,
274
+ classId: true,
275
+ class: { select: { name: true } },
276
+ },
277
+ take: limit,
278
+ }),
279
+ ]);
280
+
281
+ const results: SearchResult[] = [
282
+ ...classes.map((cls) => ({
283
+ id: cls.id,
284
+ type: "class" as const,
285
+ title: cls.name,
286
+ subtitle: [cls.subject, cls.section].filter(Boolean).join(" · "),
287
+ classId: cls.id,
288
+ className: cls.name,
289
+ href: `/class/${cls.id}`,
290
+ })),
291
+ ...assignments.map((assignment) => ({
292
+ id: assignment.id,
293
+ type: "assignment" as const,
294
+ title: assignment.title,
295
+ subtitle: `${assignment.class.name} · Assignment`,
296
+ classId: assignment.classId,
297
+ className: assignment.class.name,
298
+ href: `/class/${assignment.classId}/assignment/${assignment.id}`,
299
+ matchedText: excerpt(assignment.instructions, query),
300
+ date: assignment.dueDate ?? assignment.modifiedAt,
301
+ })),
302
+ ...events.map((event) => ({
303
+ id: event.id,
304
+ type: "event" as const,
305
+ title: event.name || "Untitled event",
306
+ subtitle: `${event.class?.name ?? "Class"} · Event`,
307
+ classId: event.classId!,
308
+ className: event.class?.name ?? "Class",
309
+ href: `/class/${event.classId}/attendance/${event.id}`,
310
+ matchedText: excerpt(event.location || event.remarks, query),
311
+ date: event.startTime,
312
+ })),
313
+ ...memberships
314
+ .filter((cls) => {
315
+ if (!cls.syllabus) return false;
316
+ if (!query) return true;
317
+ return (
318
+ includesToken(query, "syllabus") ||
319
+ cls.name.toLowerCase().includes(query.toLowerCase()) ||
320
+ cls.subject.toLowerCase().includes(query.toLowerCase()) ||
321
+ cls.syllabus.toLowerCase().includes(query.toLowerCase())
322
+ );
323
+ })
324
+ .slice(0, limit)
325
+ .map((cls) => ({
326
+ id: `${cls.id}:syllabus`,
327
+ type: "syllabus" as const,
328
+ title: `${cls.name} syllabus`,
329
+ subtitle: `${cls.subject} · Syllabus`,
330
+ classId: cls.id,
331
+ className: cls.name,
332
+ href: `/class/${cls.id}/syllabus`,
333
+ matchedText: excerpt(cls.syllabus, query),
334
+ })),
335
+ ...worksheets.map((worksheet) => ({
336
+ id: worksheet.id,
337
+ type: "worksheet" as const,
338
+ title: worksheet.name,
339
+ subtitle: `${worksheet.class?.name ?? "Class"} · Worksheet`,
340
+ classId: worksheet.classId,
341
+ className: worksheet.class?.name ?? "Class",
342
+ href: `/class/${worksheet.classId}/worksheets`,
343
+ date: worksheet.updatedAt,
344
+ })),
345
+ ...files.flatMap((file) => {
346
+ const classId =
347
+ file.folder?.classId ??
348
+ file.classDraft?.id ??
349
+ file.assignment?.classId ??
350
+ file.announcement?.classId;
351
+ const className =
352
+ file.folder?.class?.name ??
353
+ file.classDraft?.name ??
354
+ file.assignment?.class?.name ??
355
+ file.announcement?.class?.name ??
356
+ (classId ? classById.get(classId)?.name : undefined);
357
+
358
+ if (!classId || !className) return [];
359
+
360
+ return [
361
+ {
362
+ id: file.id,
363
+ type: "file" as const,
364
+ title: file.name,
365
+ subtitle: `${className} · File`,
366
+ classId,
367
+ className,
368
+ href: `/class/${classId}/files`,
369
+ matchedText: file.type,
370
+ date: file.uploadedAt,
371
+ },
372
+ ];
373
+ }),
374
+ ...announcements.map((announcement) => ({
375
+ id: announcement.id,
376
+ type: "announcement" as const,
377
+ title: excerpt(announcement.remarks, query) ?? "Announcement",
378
+ subtitle: `${announcement.class.name} · Announcement`,
379
+ classId: announcement.classId,
380
+ className: announcement.class.name,
381
+ href: `/class/${announcement.classId}`,
382
+ matchedText: excerpt(announcement.remarks, query),
383
+ date: announcement.createdAt,
384
+ })),
385
+ ...sections.map((section) => ({
386
+ id: section.id,
387
+ type: "section" as const,
388
+ title: section.name,
389
+ subtitle: `${section.class.name} · Section`,
390
+ classId: section.classId,
391
+ className: section.class.name,
392
+ href: `/class/${section.classId}/assignments`,
393
+ })),
394
+ ];
395
+
396
+ return { query, results: trimResults(results, limit) };
397
+ }
@@ -5,7 +5,9 @@
5
5
  import { TRPCError } from "@trpc/server";
6
6
  import { GenerationStatus, type WorksheetQuestionType } from "@prisma/client";
7
7
  import { prisma } from "../lib/prisma.js";
8
+ import { getFileUrl } from "../lib/fileUpload.js";
8
9
  import { cancelGradePipeline, regradeWorksheetPipeline } from "../pipelines/gradeWorksheet.js";
10
+ import { pdfToWorksheet } from "../pipelines/pdfToWorksheet.js";
9
11
  import {
10
12
  findWorksheetByIdMinimal,
11
13
  findWorksheetByIdForClassMember,
@@ -121,6 +123,65 @@ export async function createWorksheetRecord(classId: string, name: string) {
121
123
  return createWorksheet({ classId, name });
122
124
  }
123
125
 
126
+ /**
127
+ * Create a worksheet by converting a PDF (URL) into structured questions.
128
+ * Uses the `pdfToWorksheet` pipeline to do inference + persistence.
129
+ */
130
+ export async function createWorksheetFromPdf(
131
+ classId: string,
132
+ url: string
133
+ ) {
134
+ const created = await pdfToWorksheet(url, classId);
135
+ if (!created) {
136
+ throw new TRPCError({
137
+ code: "INTERNAL_SERVER_ERROR",
138
+ message: "Failed to create worksheet from PDF",
139
+ });
140
+ }
141
+ return created;
142
+ }
143
+
144
+ /**
145
+ * Create a worksheet from a PDF that was uploaded through the direct-upload flow.
146
+ */
147
+ export async function createWorksheetFromUploadedPdf(
148
+ classId: string,
149
+ fileId: string,
150
+ userId: string
151
+ ) {
152
+ const file = await prisma.file.findFirst({
153
+ where: {
154
+ id: fileId,
155
+ userId,
156
+ uploadStatus: "COMPLETED",
157
+ },
158
+ select: {
159
+ id: true,
160
+ path: true,
161
+ type: true,
162
+ },
163
+ });
164
+
165
+ if (!file) {
166
+ throw new TRPCError({
167
+ code: "NOT_FOUND",
168
+ message: "Uploaded file not found or not ready",
169
+ });
170
+ }
171
+
172
+ const isPdf =
173
+ file.type === "application/pdf" || file.path.toLowerCase().endsWith(".pdf");
174
+ if (!isPdf) {
175
+ throw new TRPCError({
176
+ code: "BAD_REQUEST",
177
+ message: "Only PDF files can be converted into worksheets",
178
+ });
179
+ }
180
+
181
+ const signedUrl = await getFileUrl(file.path);
182
+ return createWorksheetFromPdf(classId, signedUrl);
183
+ }
184
+
124
185
  export async function addQuestionToWorksheet(
125
186
  worksheetId: string,
126
187
  userId: string,
@@ -2,6 +2,7 @@
2
2
  * Inference util – OpenAI client, inference() for structured output, sendAIMessage for chat.
3
3
  */
4
4
  import OpenAI from 'openai';
5
+ import type { ResponseInput } from "openai/resources/responses/responses.js";
5
6
  import { logger } from './logger.js';
6
7
  import { prisma } from '../lib/prisma.js';
7
8
  import { chatChannel, pusher } from '../lib/pusher.js';
@@ -275,25 +276,93 @@ export async function sendAIMessage(
275
276
  };
276
277
  }
277
278
 
279
+ /** Responses API user parts use `type: "input_*"`; chat completions use `text` / `image_url` / etc. */
280
+ function isResponseInputWithInputParts(
281
+ content: OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
282
+ ): content is ResponseInput {
283
+ if (!Array.isArray(content)) return false;
284
+ for (const msg of content) {
285
+ if (!msg || typeof msg !== "object" || !("content" in msg)) continue;
286
+ const parts = (msg as { content?: unknown }).content;
287
+ if (!Array.isArray(parts)) continue;
288
+ for (const part of parts) {
289
+ if (
290
+ part &&
291
+ typeof part === "object" &&
292
+ "type" in part &&
293
+ typeof (part as { type: string }).type === "string" &&
294
+ (part as { type: string }).type.startsWith("input_")
295
+ ) {
296
+ return true;
297
+ }
298
+ }
299
+ }
300
+ return false;
301
+ }
302
+
303
+ function buildResponsesParseInput(
304
+ content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[] | ResponseInput
305
+ ): ResponseInput {
306
+ if (typeof content === "string") {
307
+ return [{ role: "user", content: [{ type: "input_text", text: content }] }];
308
+ }
309
+ if (isResponseInputWithInputParts(content)) {
310
+ return content;
311
+ }
312
+ return content as unknown as ResponseInput;
313
+ }
314
+
278
315
  export async function inference<T>(
279
- content: string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
316
+ content:
317
+ | string
318
+ | OpenAI.Chat.Completions.ChatCompletionMessageParam[]
319
+ | ResponseInput,
280
320
  format?: ZodSchema,
281
321
  options?: InferenceOptions,
282
322
  ): Promise<T> {
283
- const input = normalizeInferenceInput(content);
284
323
  const reason = getInferenceReason(options);
285
324
  const model = options?.model ?? DEFAULT_INFERENCE_MODEL;
286
- const estimatedInputTokens = input.reduce(
287
- (total, message) => total + estimateTokenCount(message.content),
288
- 0,
289
- );
325
+ const estimatedInputTokens =
326
+ typeof content === 'string'
327
+ ? estimateTokenCount(content)
328
+ : isResponseInputWithInputParts(content)
329
+ ? estimateTokenCount(
330
+ content
331
+ .flatMap((item) =>
332
+ typeof (item as any)?.content === 'string'
333
+ ? [(item as any).content]
334
+ : Array.isArray((item as any)?.content)
335
+ ? (item as any).content
336
+ .filter((p: any) => p?.type === 'input_text')
337
+ .map((p: any) => p?.text ?? '')
338
+ : [],
339
+ )
340
+ .join('\n'),
341
+ )
342
+ : (content as OpenAI.Chat.Completions.ChatCompletionMessageParam[]).reduce(
343
+ (total, message) =>
344
+ total +
345
+ estimateTokenCount(
346
+ typeof message.content === 'string' ? message.content : '',
347
+ ),
348
+ 0,
349
+ );
290
350
  const operation = format ? 'responses.parse' : 'chat.completions';
291
351
 
292
352
  try {
293
353
  if (!format) {
354
+ if (typeof content !== 'string' && isResponseInputWithInputParts(content)) {
355
+ throw new Error(
356
+ 'normalizeInferenceInput cannot handle ResponseInput content with input_* parts when format is undefined; pass a format to use responses.parse.',
357
+ );
358
+ }
359
+
360
+ const messages = normalizeInferenceInput(
361
+ content as string | OpenAI.Chat.Completions.ChatCompletionMessageParam[],
362
+ );
294
363
  const completion = await openAIClient.chat.completions.create({
295
364
  model,
296
- messages: input,
365
+ messages,
297
366
  ...(options?.temperature !== undefined
298
367
  ? { temperature: options.temperature }
299
368
  : {}),
@@ -315,6 +384,7 @@ export async function inference<T>(
315
384
  return completion.choices[0]?.message?.content as T;
316
385
  }
317
386
 
387
+ const input = buildResponsesParseInput(content);
318
388
  const completion = await openAIClient.responses.parse({
319
389
  model,
320
390
  input,