@proteinjs/conversation 2.5.0 → 2.6.0

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.
@@ -0,0 +1,1076 @@
1
+ import { OpenAI as OpenAIApi } from 'openai';
2
+ import type { ChatCompletionMessageParam } from 'openai/resources/chat';
3
+ import { Logger, LogLevel } from '@proteinjs/logger';
4
+ import type { ConversationModule } from './ConversationModule';
5
+ import type { Function } from './Function';
6
+ import { UsageData, UsageDataAccumulator } from './UsageData';
7
+ import { ChatCompletionMessageParamFactory } from './ChatCompletionMessageParamFactory';
8
+ import type { GenerateResponseReturn, ToolInvocationProgressEvent, ToolInvocationResult } from './OpenAi';
9
+ import { DEFAULT_MODEL } from './OpenAi';
10
+
11
+ export const DEFAULT_RESPONSES_MODEL = 'gpt-5.2';
12
+ export const DEFAULT_MAX_TOOL_CALLS = 50;
13
+
14
+ export type OpenAiResponsesParams = {
15
+ modules?: ConversationModule[];
16
+ /** If provided, only these functions will be exposed to the model. */
17
+ allowedFunctionNames?: string[];
18
+ logLevel?: LogLevel;
19
+
20
+ /** Default model when none is provided per call. */
21
+ defaultModel?: string;
22
+
23
+ /** Default cap for tool calls (per call). */
24
+ maxToolCalls?: number;
25
+ };
26
+
27
+ export type GenerateTextParams = {
28
+ messages: (string | ChatCompletionMessageParam)[];
29
+ model?: string;
30
+
31
+ abortSignal?: AbortSignal;
32
+
33
+ /** Sampling & limits */
34
+ temperature?: number;
35
+ topP?: number;
36
+ maxTokens?: number;
37
+
38
+ /** Optional realtime hook for tool-call lifecycle (started/finished). */
39
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
40
+
41
+ /** Usage callback */
42
+ onUsageData?: (usageData: UsageData) => Promise<void>;
43
+
44
+ /** Per-call override for reasoning effort (reasoning models only). */
45
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
46
+
47
+ /** Hard cap for custom function tool calls executed by this wrapper. */
48
+ maxToolCalls?: number;
49
+
50
+ /** If true, run using Responses API background mode (polling). */
51
+ backgroundMode?: boolean;
52
+ };
53
+
54
+ export type ResponsesGenerateObjectParams<S> = {
55
+ messages: (string | ChatCompletionMessageParam)[];
56
+ model?: string;
57
+
58
+ abortSignal?: AbortSignal;
59
+
60
+ /** Zod schema or JSON Schema */
61
+ schema: S;
62
+
63
+ /** Sampling & limits */
64
+ temperature?: number;
65
+ topP?: number;
66
+ maxTokens?: number;
67
+
68
+ /** Optional realtime hook for tool-call lifecycle (started/finished). */
69
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
70
+
71
+ /** Usage callback */
72
+ onUsageData?: (usageData: UsageData) => Promise<void>;
73
+
74
+ /** Per-call override for reasoning effort (reasoning models only). */
75
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
76
+
77
+ /** Hard cap for custom function tool calls executed by this wrapper. */
78
+ maxToolCalls?: number;
79
+
80
+ /** If true, run using Responses API background mode (polling). */
81
+ backgroundMode?: boolean;
82
+ };
83
+
84
+ /**
85
+ * OpenAI Responses API wrapper (tool-loop + usage tracking + ConversationModules).
86
+ * - Uses Responses API directly
87
+ * - Supports custom function tools (tool calling loop)
88
+ * - Supports structured outputs (JSON schema / Zod)
89
+ * - Tracks usage + tool calls using existing types
90
+ * - Supports background mode (polling)
91
+ * - Supports ConversationModules (system messages + tool registration)
92
+ */
93
+ export class OpenAiResponses {
94
+ private readonly client: OpenAIApi;
95
+ private readonly logger: Logger;
96
+
97
+ private readonly modules: ConversationModule[];
98
+ private readonly allowedFunctionNames?: string[];
99
+ private readonly defaultModel: string;
100
+ private readonly defaultMaxToolCalls: number;
101
+
102
+ private modulesProcessed = false;
103
+ private processingModulesPromise: Promise<void> | null = null;
104
+
105
+ private systemMessages: string[] = [];
106
+ private functions: Function[] = [];
107
+
108
+ constructor(opts: OpenAiResponsesParams = {}) {
109
+ this.client = new OpenAIApi();
110
+ this.logger = new Logger({ name: 'OpenAiResponses', logLevel: opts.logLevel });
111
+
112
+ this.modules = opts.modules ?? [];
113
+ this.allowedFunctionNames = opts.allowedFunctionNames;
114
+
115
+ this.defaultModel = (opts.defaultModel ?? DEFAULT_RESPONSES_MODEL).trim();
116
+ this.defaultMaxToolCalls = typeof opts.maxToolCalls === 'number' ? opts.maxToolCalls : DEFAULT_MAX_TOOL_CALLS;
117
+ }
118
+
119
+ /** Plain text generation (supports tool calling). */
120
+ async generateText(args: GenerateTextParams): Promise<GenerateResponseReturn> {
121
+ await this.ensureModulesProcessed();
122
+
123
+ const model = this.resolveModel(args.model);
124
+ const backgroundMode = this.resolveBackgroundMode({
125
+ requested: args.backgroundMode,
126
+ model,
127
+ reasoningEffort: args.reasoningEffort,
128
+ });
129
+
130
+ const maxToolCalls = typeof args.maxToolCalls === 'number' ? args.maxToolCalls : this.defaultMaxToolCalls;
131
+
132
+ const result = await this.run({
133
+ model,
134
+ messages: args.messages,
135
+ temperature: args.temperature,
136
+ topP: args.topP,
137
+ maxTokens: args.maxTokens,
138
+ abortSignal: args.abortSignal,
139
+ onToolInvocation: args.onToolInvocation,
140
+ reasoningEffort: args.reasoningEffort,
141
+ maxToolCalls,
142
+ backgroundMode,
143
+ textFormat: undefined,
144
+ });
145
+
146
+ if (args.onUsageData) {
147
+ await args.onUsageData(result.usagedata);
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ /** Back-compat alias for callers that use `generateResponse`. */
154
+ async generateResponse(args: GenerateTextParams): Promise<GenerateResponseReturn> {
155
+ return this.generateText(args);
156
+ }
157
+
158
+ /** Structured object generation (supports tool calling). */
159
+ async generateObject<T>(args: ResponsesGenerateObjectParams<unknown>): Promise<{ object: T; usageData: UsageData }> {
160
+ await this.ensureModulesProcessed();
161
+
162
+ const model = this.resolveModel(args.model);
163
+ const backgroundMode = this.resolveBackgroundMode({
164
+ requested: args.backgroundMode,
165
+ model,
166
+ reasoningEffort: args.reasoningEffort,
167
+ });
168
+
169
+ const maxToolCalls = typeof args.maxToolCalls === 'number' ? args.maxToolCalls : this.defaultMaxToolCalls;
170
+ const textFormat = this.buildTextFormat(args.schema);
171
+
172
+ const result = await this.run({
173
+ model,
174
+ messages: args.messages,
175
+ temperature: args.temperature,
176
+ topP: args.topP,
177
+ maxTokens: args.maxTokens,
178
+ abortSignal: args.abortSignal,
179
+ onToolInvocation: args.onToolInvocation,
180
+ reasoningEffort: args.reasoningEffort,
181
+ maxToolCalls,
182
+ backgroundMode,
183
+ textFormat,
184
+ });
185
+
186
+ const object = this.parseAndValidateStructuredOutput<T>(result.message, args.schema);
187
+
188
+ const outcome = {
189
+ object,
190
+ usageData: result.usagedata,
191
+ };
192
+
193
+ if (args.onUsageData) {
194
+ await args.onUsageData(outcome.usageData);
195
+ }
196
+
197
+ return outcome;
198
+ }
199
+
200
+ // -----------------------------------------
201
+ // Core runner (tool loop)
202
+ // -----------------------------------------
203
+
204
+ private async run(args: {
205
+ model: string;
206
+ messages: (string | ChatCompletionMessageParam)[];
207
+
208
+ temperature?: number;
209
+ topP?: number;
210
+ maxTokens?: number;
211
+
212
+ abortSignal?: AbortSignal;
213
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
214
+
215
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
216
+
217
+ maxToolCalls: number;
218
+ backgroundMode: boolean;
219
+
220
+ textFormat?: unknown;
221
+ }): Promise<GenerateResponseReturn> {
222
+ // UsageDataAccumulator is typed around TiktokenModel; keep accumulator model stable,
223
+ // and (optionally) report the actual model via upstream telemetry if you later choose to.
224
+ const usage = new UsageDataAccumulator({ model: DEFAULT_MODEL });
225
+ const toolInvocations: ToolInvocationResult[] = [];
226
+
227
+ const tools = this.buildResponseTools(this.functions);
228
+
229
+ const { instructions, input } = this.buildInstructionsAndInput(args.messages);
230
+
231
+ let toolCallsExecuted = 0;
232
+ let previousResponseId: string | undefined;
233
+ let nextInput: unknown = input;
234
+
235
+ for (;;) {
236
+ const response = await this.createResponseAndMaybeWait({
237
+ model: args.model,
238
+ instructions: previousResponseId ? undefined : instructions,
239
+ input: nextInput,
240
+ previousResponseId,
241
+ tools,
242
+ temperature: args.temperature,
243
+ topP: args.topP,
244
+ maxTokens: args.maxTokens,
245
+ reasoningEffort: args.reasoningEffort,
246
+ textFormat: args.textFormat,
247
+ backgroundMode: args.backgroundMode,
248
+ abortSignal: args.abortSignal,
249
+ });
250
+
251
+ this.addUsageFromResponse(response, usage);
252
+
253
+ const functionCalls = this.extractFunctionCalls(response);
254
+ if (functionCalls.length < 1) {
255
+ const message = this.extractAssistantText(response);
256
+ if (!message) {
257
+ throw new Error(`Response was empty`);
258
+ }
259
+ return { message, usagedata: usage.usageData, toolInvocations };
260
+ }
261
+
262
+ if (toolCallsExecuted + functionCalls.length > args.maxToolCalls) {
263
+ throw new Error(`Max tool calls (${args.maxToolCalls}) reached. Stopping execution.`);
264
+ }
265
+
266
+ if (!response.id) {
267
+ throw new Error(`Responses API did not return an id for a tool-calling response.`);
268
+ }
269
+
270
+ const toolOutputs = await this.executeFunctionCalls({
271
+ calls: functionCalls,
272
+ functions: this.functions,
273
+ usage,
274
+ toolInvocations,
275
+ onToolInvocation: args.onToolInvocation,
276
+ });
277
+
278
+ toolCallsExecuted += functionCalls.length;
279
+
280
+ previousResponseId = response.id;
281
+ nextInput = toolOutputs;
282
+
283
+ this.logger.debug({
284
+ message: `Tool loop continuing`,
285
+ obj: { toolCallsExecuted, lastToolCallCount: functionCalls.length, responseId: previousResponseId },
286
+ });
287
+ }
288
+ }
289
+
290
+ private async createResponseAndMaybeWait(args: {
291
+ model: string;
292
+ instructions?: string;
293
+ input: unknown;
294
+ previousResponseId?: string;
295
+
296
+ tools: Array<{ type: 'function'; name: string; description?: string; parameters?: unknown; strict?: boolean }>;
297
+ temperature?: number;
298
+ topP?: number;
299
+ maxTokens?: number;
300
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
301
+
302
+ textFormat?: unknown;
303
+
304
+ backgroundMode: boolean;
305
+ abortSignal?: AbortSignal;
306
+ }): Promise<{
307
+ id?: string;
308
+ status?: string;
309
+ output_text?: string;
310
+ output?: unknown[];
311
+ usage?: unknown;
312
+ }> {
313
+ const body: Record<string, unknown> = {
314
+ model: args.model,
315
+ input: args.input,
316
+ };
317
+
318
+ if (args.instructions) {
319
+ body.instructions = args.instructions;
320
+ }
321
+
322
+ if (args.previousResponseId) {
323
+ body.previous_response_id = args.previousResponseId;
324
+ }
325
+
326
+ if (args.tools.length > 0) {
327
+ body.tools = args.tools;
328
+ }
329
+
330
+ if (typeof args.temperature === 'number') {
331
+ body.temperature = args.temperature;
332
+ }
333
+ if (typeof args.topP === 'number') {
334
+ body.top_p = args.topP;
335
+ }
336
+ if (typeof args.maxTokens === 'number') {
337
+ body.max_output_tokens = args.maxTokens;
338
+ }
339
+ if (args.reasoningEffort) {
340
+ body.reasoning = { effort: args.reasoningEffort };
341
+ }
342
+ if (args.textFormat) {
343
+ body.text = { format: args.textFormat };
344
+ }
345
+
346
+ if (args.backgroundMode) {
347
+ body.background = true;
348
+ body.store = true;
349
+ }
350
+
351
+ const created = await this.client.responses.create(
352
+ body as never,
353
+ args.abortSignal ? { signal: args.abortSignal } : undefined
354
+ );
355
+
356
+ if (!args.backgroundMode) {
357
+ return created as unknown as {
358
+ id?: string;
359
+ status?: string;
360
+ output_text?: string;
361
+ output?: unknown[];
362
+ usage?: unknown;
363
+ };
364
+ }
365
+
366
+ if (!created?.id) {
367
+ return created as unknown as {
368
+ id?: string;
369
+ status?: string;
370
+ output_text?: string;
371
+ output?: unknown[];
372
+ usage?: unknown;
373
+ };
374
+ }
375
+
376
+ return await this.waitForCompletion(created.id, args.abortSignal);
377
+ }
378
+
379
+ private async waitForCompletion(
380
+ responseId: string,
381
+ abortSignal?: AbortSignal
382
+ ): Promise<{
383
+ id?: string;
384
+ status?: string;
385
+ output_text?: string;
386
+ output?: unknown[];
387
+ usage?: unknown;
388
+ }> {
389
+ let delayMs = 500;
390
+
391
+ for (;;) {
392
+ if (abortSignal?.aborted) {
393
+ throw new Error(`Request aborted`);
394
+ }
395
+
396
+ const resp = await this.client.responses.retrieve(
397
+ responseId,
398
+ undefined,
399
+ abortSignal ? { signal: abortSignal } : undefined
400
+ );
401
+
402
+ const status = typeof (resp as any)?.status === 'string' ? String((resp as any).status).toLowerCase() : '';
403
+ if (status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'incomplete') {
404
+ return resp as unknown as {
405
+ id?: string;
406
+ status?: string;
407
+ output_text?: string;
408
+ output?: unknown[];
409
+ usage?: unknown;
410
+ };
411
+ }
412
+
413
+ this.logger.debug({ message: `Polling response`, obj: { responseId, status, delayMs } });
414
+
415
+ await sleep(delayMs);
416
+ delayMs = Math.min(5000, Math.floor(delayMs * 1.5));
417
+ }
418
+ }
419
+
420
+ // -----------------------------------------
421
+ // Tool calls
422
+ // -----------------------------------------
423
+
424
+ private buildResponseTools(
425
+ functions: Function[]
426
+ ): Array<{ type: 'function'; name: string; description?: string; parameters?: unknown; strict?: boolean }> {
427
+ const tools: Array<{
428
+ type: 'function';
429
+ name: string;
430
+ description?: string;
431
+ parameters?: unknown;
432
+ strict?: boolean;
433
+ }> = [];
434
+
435
+ if (!functions || functions.length < 1) {
436
+ return tools;
437
+ }
438
+
439
+ for (const f of functions) {
440
+ const def = f.definition;
441
+ if (!def?.name) {
442
+ continue;
443
+ }
444
+
445
+ tools.push({
446
+ type: 'function',
447
+ name: def.name,
448
+ description: def.description,
449
+ parameters: def.parameters,
450
+ // strict: true,
451
+ });
452
+ }
453
+
454
+ return tools;
455
+ }
456
+
457
+ private extractFunctionCalls(response: { output?: unknown[] }): Array<{
458
+ type: 'function_call';
459
+ call_id: string;
460
+ name: string;
461
+ arguments: string;
462
+ }> {
463
+ const out = Array.isArray(response.output) ? response.output : [];
464
+ const calls: Array<{ type: 'function_call'; call_id: string; name: string; arguments: string }> = [];
465
+
466
+ for (const item of out) {
467
+ if (!item || typeof item !== 'object') {
468
+ continue;
469
+ }
470
+ const rec = item as Record<string, unknown>;
471
+ if (rec.type !== 'function_call') {
472
+ continue;
473
+ }
474
+
475
+ const call_id = typeof rec.call_id === 'string' ? rec.call_id : '';
476
+ const name = typeof rec.name === 'string' ? rec.name : '';
477
+ const args = typeof rec.arguments === 'string' ? rec.arguments : '';
478
+
479
+ if (!call_id || !name) {
480
+ continue;
481
+ }
482
+
483
+ calls.push({ type: 'function_call', call_id, name, arguments: args });
484
+ }
485
+
486
+ return calls;
487
+ }
488
+
489
+ private async executeFunctionCalls(args: {
490
+ calls: Array<{ type: 'function_call'; call_id: string; name: string; arguments: string }>;
491
+ functions: Function[];
492
+ usage: UsageDataAccumulator;
493
+ toolInvocations: ToolInvocationResult[];
494
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
495
+ }): Promise<Array<{ type: 'function_call_output'; call_id: string; output: string }>> {
496
+ const outputs: Array<{ type: 'function_call_output'; call_id: string; output: string }> = [];
497
+
498
+ for (const call of args.calls) {
499
+ outputs.push(
500
+ await this.executeFunctionCall({
501
+ call,
502
+ functions: args.functions,
503
+ usage: args.usage,
504
+ toolInvocations: args.toolInvocations,
505
+ onToolInvocation: args.onToolInvocation,
506
+ })
507
+ );
508
+ }
509
+
510
+ return outputs;
511
+ }
512
+
513
+ private async executeFunctionCall(args: {
514
+ call: { call_id: string; name: string; arguments: string };
515
+ functions: Function[];
516
+ usage: UsageDataAccumulator;
517
+ toolInvocations: ToolInvocationResult[];
518
+ onToolInvocation?: (evt: ToolInvocationProgressEvent) => void;
519
+ }): Promise<{ type: 'function_call_output'; call_id: string; output: string }> {
520
+ const callId = args.call.call_id;
521
+ const rawName = args.call.name;
522
+ const shortName = rawName.split('.').pop() ?? rawName;
523
+
524
+ const functionToCall =
525
+ args.functions.find((fx) => fx.definition.name === rawName) ??
526
+ args.functions.find((fx) => (fx.definition.name.split('.').pop() ?? fx.definition.name) === shortName);
527
+
528
+ const startedAt = new Date();
529
+
530
+ let parsedArgs: unknown;
531
+ try {
532
+ parsedArgs = JSON.parse(args.call.arguments ?? '{}');
533
+ } catch {
534
+ parsedArgs = args.call.arguments;
535
+ }
536
+
537
+ args.onToolInvocation?.({
538
+ type: 'started',
539
+ id: callId,
540
+ name: functionToCall?.definition?.name ?? shortName,
541
+ startedAt,
542
+ input: parsedArgs,
543
+ });
544
+
545
+ if (!functionToCall) {
546
+ const finishedAt = new Date();
547
+ const rec: ToolInvocationResult = {
548
+ id: callId,
549
+ name: shortName,
550
+ startedAt,
551
+ finishedAt,
552
+ input: parsedArgs,
553
+ ok: false,
554
+ error: { message: `Assistant attempted to call nonexistent function` },
555
+ };
556
+ args.toolInvocations.push(rec);
557
+ args.onToolInvocation?.({ type: 'finished', result: rec });
558
+
559
+ return {
560
+ type: 'function_call_output',
561
+ call_id: callId,
562
+ output: JSON.stringify({ error: rec.error?.message, functionName: shortName }),
563
+ };
564
+ }
565
+
566
+ try {
567
+ let argsObj: unknown;
568
+ try {
569
+ argsObj = JSON.parse(args.call.arguments ?? '{}');
570
+ } catch {
571
+ argsObj = {};
572
+ }
573
+
574
+ args.usage.recordToolCall(functionToCall.definition.name);
575
+
576
+ const returnObject = await functionToCall.call(argsObj);
577
+ const finishedAt = new Date();
578
+
579
+ const rec: ToolInvocationResult = {
580
+ id: callId,
581
+ name: functionToCall.definition.name,
582
+ startedAt,
583
+ finishedAt,
584
+ input: argsObj,
585
+ ok: true,
586
+ data: returnObject,
587
+ };
588
+ args.toolInvocations.push(rec);
589
+ args.onToolInvocation?.({ type: 'finished', result: rec });
590
+
591
+ const output = await this.formatToolReturn(returnObject);
592
+
593
+ return {
594
+ type: 'function_call_output',
595
+ call_id: callId,
596
+ output,
597
+ };
598
+ } catch (error: unknown) {
599
+ const finishedAt = new Date();
600
+
601
+ const errMessage = error instanceof Error ? error.message : String(error);
602
+ const errStack = error instanceof Error ? error.stack : undefined;
603
+
604
+ const rec: ToolInvocationResult = {
605
+ id: callId,
606
+ name: functionToCall.definition.name,
607
+ startedAt,
608
+ finishedAt,
609
+ input: parsedArgs,
610
+ ok: false,
611
+ error: { message: errMessage, stack: errStack },
612
+ };
613
+ args.toolInvocations.push(rec);
614
+ args.onToolInvocation?.({ type: 'finished', result: rec });
615
+
616
+ throw error;
617
+ }
618
+ }
619
+
620
+ private async formatToolReturn(returnObject: unknown): Promise<string> {
621
+ if (typeof returnObject === 'undefined') {
622
+ return JSON.stringify({ result: 'Function with no return value executed successfully' });
623
+ }
624
+
625
+ if (returnObject instanceof ChatCompletionMessageParamFactory) {
626
+ const messageParams = await returnObject.create();
627
+ const normalized = (messageParams ?? [])
628
+ .map((m) => ({
629
+ role: m.role,
630
+ content: this.extractTextContent(m.content),
631
+ }))
632
+ .filter((m) => typeof m.content === 'string' && m.content.trim().length > 0);
633
+
634
+ return JSON.stringify({ messages: normalized });
635
+ }
636
+
637
+ return JSON.stringify(returnObject);
638
+ }
639
+
640
+ // -----------------------------------------
641
+ // Usage + text extraction
642
+ // -----------------------------------------
643
+
644
+ private addUsageFromResponse(response: { usage?: unknown }, usage: UsageDataAccumulator): void {
645
+ const u = response.usage;
646
+ if (!u || typeof u !== 'object') {
647
+ return;
648
+ }
649
+
650
+ const rec = u as Record<string, unknown>;
651
+ const input = typeof rec.input_tokens === 'number' ? rec.input_tokens : 0;
652
+ const output = typeof rec.output_tokens === 'number' ? rec.output_tokens : 0;
653
+ const total = typeof rec.total_tokens === 'number' ? rec.total_tokens : input + output;
654
+
655
+ let cached = 0;
656
+ let reasoning = 0;
657
+
658
+ const inputDetails = rec.input_tokens_details;
659
+ if (inputDetails && typeof inputDetails === 'object') {
660
+ const id = inputDetails as Record<string, unknown>;
661
+ cached = typeof id.cached_tokens === 'number' ? id.cached_tokens : 0;
662
+ }
663
+
664
+ const outputDetails = rec.output_tokens_details;
665
+ if (outputDetails && typeof outputDetails === 'object') {
666
+ const od = outputDetails as Record<string, unknown>;
667
+ reasoning = typeof od.reasoning_tokens === 'number' ? od.reasoning_tokens : 0;
668
+ }
669
+
670
+ usage.addTokenUsage({
671
+ promptTokens: input,
672
+ cachedPromptTokens: cached,
673
+ completionTokens: output,
674
+ reasoningTokens: reasoning,
675
+ totalTokens: total,
676
+ });
677
+ }
678
+
679
+ private extractAssistantText(response: { output_text?: string; output?: unknown[] }): string {
680
+ const direct = typeof response.output_text === 'string' ? response.output_text.trim() : '';
681
+ if (direct) {
682
+ return direct;
683
+ }
684
+
685
+ const out = Array.isArray(response.output) ? response.output : [];
686
+ for (const item of out) {
687
+ if (!item || typeof item !== 'object') {
688
+ continue;
689
+ }
690
+ const rec = item as Record<string, unknown>;
691
+ if (rec.type !== 'message') {
692
+ continue;
693
+ }
694
+ if (rec.role !== 'assistant') {
695
+ continue;
696
+ }
697
+
698
+ const contentRaw = rec.content;
699
+ if (!Array.isArray(contentRaw)) {
700
+ continue;
701
+ }
702
+
703
+ const pieces: string[] = [];
704
+ for (const c of contentRaw) {
705
+ if (!c || typeof c !== 'object') {
706
+ continue;
707
+ }
708
+ const part = c as Record<string, unknown>;
709
+ if (part.type !== 'output_text') {
710
+ continue;
711
+ }
712
+ const t = part.text;
713
+ if (typeof t === 'string' && t.trim()) {
714
+ pieces.push(t);
715
+ }
716
+ }
717
+
718
+ const joined = pieces.join('\n').trim();
719
+ if (joined) {
720
+ return joined;
721
+ }
722
+ }
723
+
724
+ return '';
725
+ }
726
+
727
+ // -----------------------------------------
728
+ // Structured outputs (JSON schema / Zod)
729
+ // -----------------------------------------
730
+
731
+ private buildTextFormat(schema: unknown): unknown {
732
+ if (this.isZodSchema(schema)) {
733
+ // Prefer the official helper when schema is Zod.
734
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
735
+ const mod = require('openai/helpers/zod');
736
+ return mod.zodTextFormat(schema, 'output');
737
+ }
738
+
739
+ return {
740
+ type: 'json_schema',
741
+ name: 'output',
742
+ strict: true,
743
+ schema: this.strictifyJsonSchema(schema),
744
+ };
745
+ }
746
+
747
+ private parseAndValidateStructuredOutput<T>(text: string, schema: unknown): T {
748
+ const parsed = this.parseJson(text);
749
+
750
+ if (this.isZodSchema(schema)) {
751
+ const res = schema.safeParse(parsed);
752
+ if (!res?.success) {
753
+ throw new Error(`Structured output failed schema validation`);
754
+ }
755
+ return res.data as T;
756
+ }
757
+
758
+ return parsed as T;
759
+ }
760
+
761
+ private isZodSchema(schema: unknown): schema is { safeParse: (input: unknown) => { success: boolean; data?: any } } {
762
+ if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) {
763
+ return false;
764
+ }
765
+ return typeof (schema as any).safeParse === 'function';
766
+ }
767
+
768
+ private parseJson(text: string): any {
769
+ const cleaned = String(text ?? '')
770
+ .trim()
771
+ .replace(/^```(?:json)?/i, '')
772
+ .replace(/```$/i, '')
773
+ .trim();
774
+
775
+ try {
776
+ return JSON.parse(cleaned);
777
+ } catch {
778
+ const s = cleaned;
779
+ const firstObj = s.indexOf('{');
780
+ const firstArr = s.indexOf('[');
781
+ const start = firstObj === -1 ? firstArr : firstArr === -1 ? firstObj : Math.min(firstObj, firstArr);
782
+
783
+ const lastObj = s.lastIndexOf('}');
784
+ const lastArr = s.lastIndexOf(']');
785
+ const end = Math.max(lastObj, lastArr);
786
+
787
+ if (start >= 0 && end > start) {
788
+ return JSON.parse(s.slice(start, end + 1));
789
+ }
790
+
791
+ throw new Error(`Failed to parse model output as JSON`);
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Strictifies a plain JSON Schema for OpenAI Structured Outputs (strict mode):
797
+ * - Ensures every object has `additionalProperties: false`
798
+ * - Ensures every object has a `required` array that includes **all** keys in `properties`
799
+ * - Adds missing `type: "object"` / `type: "array"` where implied by keywords
800
+ */
801
+ private strictifyJsonSchema(schema: unknown): any {
802
+ const root = JSON.parse(JSON.stringify(schema ?? {}));
803
+
804
+ const visit = (node: any) => {
805
+ if (!node || typeof node !== 'object') {
806
+ return;
807
+ }
808
+
809
+ if (!node.type) {
810
+ if (node.properties || node.additionalProperties || node.patternProperties) {
811
+ node.type = 'object';
812
+ } else if (node.items || node.prefixItems) {
813
+ node.type = 'array';
814
+ }
815
+ }
816
+
817
+ const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : [];
818
+
819
+ if (types.includes('object')) {
820
+ if (node.additionalProperties !== false) {
821
+ node.additionalProperties = false;
822
+ }
823
+
824
+ if (node.properties && typeof node.properties === 'object') {
825
+ const propKeys = Object.keys(node.properties);
826
+ const currentReq: string[] = Array.isArray(node.required) ? node.required.slice() : [];
827
+ node.required = Array.from(new Set([...currentReq, ...propKeys]));
828
+
829
+ for (const k of propKeys) {
830
+ visit(node.properties[k]);
831
+ }
832
+ }
833
+
834
+ if (node.patternProperties && typeof node.patternProperties === 'object') {
835
+ for (const k of Object.keys(node.patternProperties)) {
836
+ visit(node.patternProperties[k]);
837
+ }
838
+ }
839
+
840
+ for (const defsKey of ['$defs', 'definitions']) {
841
+ if (node[defsKey] && typeof node[defsKey] === 'object') {
842
+ for (const key of Object.keys(node[defsKey])) {
843
+ visit(node[defsKey][key]);
844
+ }
845
+ }
846
+ }
847
+ }
848
+
849
+ if (types.includes('array')) {
850
+ if (node.items) {
851
+ if (Array.isArray(node.items)) {
852
+ node.items.forEach(visit);
853
+ } else {
854
+ visit(node.items);
855
+ }
856
+ }
857
+ if (Array.isArray(node.prefixItems)) {
858
+ node.prefixItems.forEach(visit);
859
+ }
860
+ }
861
+
862
+ for (const k of ['oneOf', 'anyOf', 'allOf']) {
863
+ if (Array.isArray(node[k])) {
864
+ node[k].forEach(visit);
865
+ }
866
+ }
867
+
868
+ if (node.not) {
869
+ visit(node.not);
870
+ }
871
+ };
872
+
873
+ visit(root);
874
+ return root;
875
+ }
876
+
877
+ // -----------------------------------------
878
+ // Messages + modules
879
+ // -----------------------------------------
880
+
881
+ private buildInstructionsAndInput(messages: (string | ChatCompletionMessageParam)[]): {
882
+ instructions?: string;
883
+ input: Array<{ role: 'user' | 'assistant'; content: string }>;
884
+ } {
885
+ const instructionsParts: string[] = [];
886
+ instructionsParts.push(...this.systemMessages);
887
+
888
+ const input: Array<{ role: 'user' | 'assistant'; content: string }> = [];
889
+
890
+ for (const m of messages) {
891
+ const msg: ChatCompletionMessageParam =
892
+ typeof m === 'string' ? ({ role: 'user', content: m } as ChatCompletionMessageParam) : m;
893
+
894
+ if (msg.role === 'system') {
895
+ const c = this.extractTextContent(msg.content).trim();
896
+ if (c) {
897
+ instructionsParts.push(c);
898
+ }
899
+ continue;
900
+ }
901
+
902
+ if (msg.role === 'tool') {
903
+ continue;
904
+ }
905
+
906
+ const role: 'user' | 'assistant' = msg.role === 'assistant' ? 'assistant' : 'user';
907
+ const content = this.extractTextContent(msg.content).trim();
908
+ if (!content) {
909
+ continue;
910
+ }
911
+
912
+ input.push({ role, content });
913
+ }
914
+
915
+ const instructions =
916
+ instructionsParts.map((s) => String(s ?? '').trim()).filter(Boolean).length > 0
917
+ ? instructionsParts
918
+ .map((s) => String(s ?? '').trim())
919
+ .filter(Boolean)
920
+ .join('\n\n')
921
+ : undefined;
922
+
923
+ return { instructions, input };
924
+ }
925
+
926
+ private extractTextContent(content: ChatCompletionMessageParam['content']): string {
927
+ if (typeof content === 'string') {
928
+ return content;
929
+ }
930
+ if (!content) {
931
+ return '';
932
+ }
933
+ if (Array.isArray(content)) {
934
+ return content
935
+ .map((p: any) => {
936
+ if (typeof p === 'string') {
937
+ return p;
938
+ }
939
+ if (p?.type === 'text' && typeof p?.text === 'string') {
940
+ return p.text;
941
+ }
942
+ return '';
943
+ })
944
+ .join('\n');
945
+ }
946
+ return '';
947
+ }
948
+
949
+ private async ensureModulesProcessed(): Promise<void> {
950
+ if (this.modulesProcessed) {
951
+ return;
952
+ }
953
+ if (this.processingModulesPromise) {
954
+ return this.processingModulesPromise;
955
+ }
956
+
957
+ this.processingModulesPromise = this.processModules();
958
+ try {
959
+ await this.processingModulesPromise;
960
+ this.modulesProcessed = true;
961
+ } catch (error: unknown) {
962
+ this.processingModulesPromise = null;
963
+ throw error;
964
+ }
965
+ }
966
+
967
+ private async processModules(): Promise<void> {
968
+ if (!this.modules || this.modules.length < 1) {
969
+ return;
970
+ }
971
+
972
+ for (const module of this.modules) {
973
+ const moduleName = module.getName();
974
+
975
+ const rawSystem = await Promise.resolve(module.getSystemMessages());
976
+ const sysArr = Array.isArray(rawSystem) ? rawSystem : rawSystem ? [rawSystem] : [];
977
+ const trimmed = sysArr.map((s) => String(s ?? '').trim()).filter(Boolean);
978
+
979
+ if (trimmed.length > 0) {
980
+ const formatted = trimmed.join('. ');
981
+ this.systemMessages.push(`The following are instructions from the ${moduleName} module:\n${formatted}`);
982
+ }
983
+
984
+ const moduleFunctions = module.getFunctions();
985
+ const filtered = this.filterFunctions(moduleFunctions);
986
+ this.functions.push(...filtered);
987
+
988
+ const fnInstructions = this.buildFunctionInstructionsMessage(moduleName, filtered);
989
+ if (fnInstructions) {
990
+ this.systemMessages.push(fnInstructions);
991
+ }
992
+ }
993
+ }
994
+
995
+ private filterFunctions(functions: Function[]): Function[] {
996
+ if (!this.allowedFunctionNames || this.allowedFunctionNames.length < 1) {
997
+ return functions;
998
+ }
999
+
1000
+ const allow = new Set(this.allowedFunctionNames.map((n) => String(n).trim()).filter(Boolean));
1001
+ return functions.filter((f) => {
1002
+ const name = String(f.definition?.name ?? '').trim();
1003
+ if (!name) {
1004
+ return false;
1005
+ }
1006
+ const short = name.split('.').pop() ?? name;
1007
+ return allow.has(name) || allow.has(short);
1008
+ });
1009
+ }
1010
+
1011
+ private buildFunctionInstructionsMessage(moduleName: string, functions: Function[]): string | null {
1012
+ let msg = `The following are instructions from functions in the ${moduleName} module:`;
1013
+ let added = false;
1014
+
1015
+ for (const f of functions) {
1016
+ const name = String(f.definition?.name ?? '').trim();
1017
+ const instructions = f.instructions;
1018
+ if (!name || !instructions || instructions.length < 1) {
1019
+ continue;
1020
+ }
1021
+
1022
+ const paragraph = instructions
1023
+ .map((s) => String(s ?? '').trim())
1024
+ .filter(Boolean)
1025
+ .join('. ');
1026
+ if (!paragraph) {
1027
+ continue;
1028
+ }
1029
+
1030
+ added = true;
1031
+ msg += ` ${name}: ${paragraph}.`;
1032
+ }
1033
+
1034
+ return added ? msg : null;
1035
+ }
1036
+
1037
+ // -----------------------------------------
1038
+ // Model/background defaults
1039
+ // -----------------------------------------
1040
+
1041
+ private resolveModel(model?: string): string {
1042
+ const m = (model ?? this.defaultModel).trim();
1043
+ return m.length > 0 ? m : DEFAULT_RESPONSES_MODEL;
1044
+ }
1045
+
1046
+ private resolveBackgroundMode(args: {
1047
+ requested?: boolean;
1048
+ model: string;
1049
+ reasoningEffort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort;
1050
+ }): boolean {
1051
+ if (typeof args.requested === 'boolean') {
1052
+ return args.requested;
1053
+ }
1054
+ if (this.isProModel(args.model)) {
1055
+ return true;
1056
+ }
1057
+ if (this.isHighReasoningEffort(args.reasoningEffort)) {
1058
+ return true;
1059
+ }
1060
+ return false;
1061
+ }
1062
+
1063
+ private isProModel(model: string): boolean {
1064
+ const m = String(model ?? '').toLowerCase();
1065
+ return /(^|[-_.])pro($|[-_.])/.test(m);
1066
+ }
1067
+
1068
+ private isHighReasoningEffort(effort?: OpenAIApi.Chat.Completions.ChatCompletionReasoningEffort): boolean {
1069
+ const v = String(effort ?? '').toLowerCase();
1070
+ return v === 'high' || v === 'xhigh';
1071
+ }
1072
+ }
1073
+
1074
+ function sleep(ms: number): Promise<void> {
1075
+ return new Promise((resolve) => setTimeout(resolve, ms));
1076
+ }