@cogitator-ai/openai-compat 19.0.16 → 20.0.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.
Files changed (42) hide show
  1. package/README.md +74 -50
  2. package/dist/client/openai-adapter.d.ts +74 -37
  3. package/dist/client/openai-adapter.d.ts.map +1 -1
  4. package/dist/client/openai-adapter.js +416 -137
  5. package/dist/client/openai-adapter.js.map +1 -1
  6. package/dist/client/storage.d.ts +4 -0
  7. package/dist/client/storage.d.ts.map +1 -1
  8. package/dist/client/storage.js +80 -60
  9. package/dist/client/storage.js.map +1 -1
  10. package/dist/client/thread-manager.d.ts +45 -31
  11. package/dist/client/thread-manager.d.ts.map +1 -1
  12. package/dist/client/thread-manager.js +157 -115
  13. package/dist/client/thread-manager.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/server/api-server.d.ts +23 -1
  19. package/dist/server/api-server.d.ts.map +1 -1
  20. package/dist/server/api-server.js +42 -23
  21. package/dist/server/api-server.js.map +1 -1
  22. package/dist/server/middleware/auth.d.ts +2 -0
  23. package/dist/server/middleware/auth.d.ts.map +1 -1
  24. package/dist/server/middleware/auth.js +25 -2
  25. package/dist/server/middleware/auth.js.map +1 -1
  26. package/dist/server/routes/assistants.d.ts.map +1 -1
  27. package/dist/server/routes/assistants.js +16 -26
  28. package/dist/server/routes/assistants.js.map +1 -1
  29. package/dist/server/routes/files.d.ts.map +1 -1
  30. package/dist/server/routes/files.js +60 -56
  31. package/dist/server/routes/files.js.map +1 -1
  32. package/dist/server/routes/runs.d.ts.map +1 -1
  33. package/dist/server/routes/runs.js +102 -105
  34. package/dist/server/routes/runs.js.map +1 -1
  35. package/dist/server/routes/shared.d.ts +27 -0
  36. package/dist/server/routes/shared.d.ts.map +1 -0
  37. package/dist/server/routes/shared.js +54 -0
  38. package/dist/server/routes/shared.js.map +1 -0
  39. package/dist/server/routes/threads.d.ts.map +1 -1
  40. package/dist/server/routes/threads.js +29 -16
  41. package/dist/server/routes/threads.js.map +1 -1
  42. package/package.json +18 -6
@@ -1,46 +1,81 @@
1
1
  /**
2
2
  * OpenAI SDK Adapter
3
3
  *
4
- * Allows using the OpenAI SDK to interact with Cogitator.
5
- * Creates an in-process adapter that can be used with the SDK's baseURL.
4
+ * Implements the OpenAI Assistants API semantics on top of Cogitator.
6
5
  */
7
6
  import { Agent } from '@cogitator-ai/core';
8
7
  import { EventEmitter } from 'events';
9
- import { ThreadManager } from './thread-manager.js';
10
8
  import { nanoid } from 'nanoid';
9
+ import { z } from 'zod';
10
+ import { ThreadManager } from './thread-manager.js';
11
+ export const COGITATOR_MODEL_ID = 'cogitator';
12
+ const ACTIVE_STATUSES = [
13
+ 'queued',
14
+ 'in_progress',
15
+ 'requires_action',
16
+ 'cancelling',
17
+ ];
18
+ const RUN_TTL_SECONDS = 600;
19
+ class RunTerminatedError extends Error {
20
+ status;
21
+ constructor(status) {
22
+ super(status === 'cancelled' ? 'Run was cancelled' : 'Run expired waiting for tool outputs');
23
+ this.status = status;
24
+ this.name = 'RunTerminatedError';
25
+ }
26
+ }
27
+ function nowSeconds() {
28
+ return Math.floor(Date.now() / 1000);
29
+ }
30
+ function isActive(status) {
31
+ return ACTIVE_STATUSES.includes(status);
32
+ }
33
+ function toAgentResponseFormat(format) {
34
+ if (!format || format === 'auto' || format.type === 'text')
35
+ return {};
36
+ if (format.type === 'json_object')
37
+ return { responseFormat: { type: 'json' } };
38
+ return {
39
+ responseFormat: { type: 'json' },
40
+ instructions: `Respond with a JSON object that matches this JSON Schema (${format.json_schema.name}):\n${JSON.stringify(format.json_schema.schema)}`,
41
+ };
42
+ }
43
+ function toZodParameters(schema) {
44
+ try {
45
+ return z.fromJSONSchema(schema ?? { type: 'object', properties: {} });
46
+ }
47
+ catch {
48
+ return z.record(z.string(), z.unknown());
49
+ }
50
+ }
51
+ function renderTranscript(messages) {
52
+ return messages
53
+ .map((m) => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${m.content}`)
54
+ .join('\n\n');
55
+ }
11
56
  /**
12
57
  * OpenAI SDK Adapter
13
58
  *
14
59
  * @example
15
60
  * ```typescript
16
- * import OpenAI from 'openai';
17
- * import { createOpenAIAdapter } from '@cogitator-ai/openai-compat';
18
- *
19
- * const adapter = createOpenAIAdapter(cogitator, {
20
- * tools: [calculator, datetime],
21
- * });
22
- *
23
- * const openai = new OpenAI({
24
- * apiKey: 'not-needed',
25
- * baseURL: adapter.baseURL,
26
- * });
27
- *
28
- * // Use OpenAI SDK as normal
29
- * const assistant = await openai.beta.assistants.create({
30
- * model: 'gpt-4o',
31
- * name: 'My Assistant',
32
- * });
61
+ * const adapter = createOpenAIAdapter(cogitator, { tools: [calculator] });
62
+ * const assistant = await adapter.createAssistant({ model: 'openai/gpt-4o' });
33
63
  * ```
34
64
  */
35
65
  export class OpenAIAdapter {
36
66
  cogitator;
37
67
  threadManager;
38
68
  runs = new Map();
69
+ activeRunByThread = new Map();
39
70
  tools;
71
+ defaultModel;
72
+ maxStoredRuns;
40
73
  constructor(cogitator, options) {
41
74
  this.cogitator = cogitator;
42
- this.threadManager = new ThreadManager();
75
+ this.threadManager = new ThreadManager(options?.storage);
43
76
  this.tools = options?.tools ?? [];
77
+ this.defaultModel = options?.defaultModel;
78
+ this.maxStoredRuns = options?.maxStoredRuns ?? 10_000;
44
79
  }
45
80
  /**
46
81
  * Get the thread manager for direct access
@@ -73,12 +108,14 @@ export class OpenAIAdapter {
73
108
  object: 'assistant',
74
109
  created_at: stored.created_at,
75
110
  name: stored.name,
76
- description: null,
111
+ description: stored.description ?? null,
77
112
  model: stored.model,
78
113
  instructions: stored.instructions,
79
114
  tools: stored.tools,
80
115
  metadata: stored.metadata,
81
116
  temperature: stored.temperature,
117
+ top_p: stored.top_p,
118
+ response_format: stored.response_format,
82
119
  };
83
120
  }
84
121
  async createThread(metadata) {
@@ -103,7 +140,10 @@ export class OpenAIAdapter {
103
140
  return this.threadManager.listMessages(threadId, options);
104
141
  }
105
142
  /**
106
- * Create and execute a run
143
+ * Create a run and start executing it in the background.
144
+ *
145
+ * Rejects when the assistant or thread does not exist, or when the thread
146
+ * already has an active run.
107
147
  */
108
148
  async createRun(threadId, request) {
109
149
  const assistant = await this.threadManager.getAssistant(request.assistant_id);
@@ -114,8 +154,12 @@ export class OpenAIAdapter {
114
154
  if (!thread) {
115
155
  throw new Error(`Thread ${threadId} not found`);
116
156
  }
157
+ const activeRunId = this.activeRunByThread.get(threadId);
158
+ if (activeRunId) {
159
+ throw new Error(`Thread ${threadId} already has an active run ${activeRunId}.`);
160
+ }
117
161
  const runId = `run_${nanoid()}`;
118
- const now = Math.floor(Date.now() / 1000);
162
+ const now = nowSeconds();
119
163
  const run = {
120
164
  id: runId,
121
165
  object: 'thread.run',
@@ -125,7 +169,7 @@ export class OpenAIAdapter {
125
169
  status: 'queued',
126
170
  required_action: null,
127
171
  last_error: null,
128
- expires_at: now + 600,
172
+ expires_at: now + RUN_TTL_SECONDS,
129
173
  started_at: null,
130
174
  cancelled_at: null,
131
175
  failed_at: null,
@@ -133,44 +177,44 @@ export class OpenAIAdapter {
133
177
  incomplete_details: null,
134
178
  model: request.model ?? assistant.model,
135
179
  instructions: request.instructions ?? assistant.instructions,
136
- tools: (request.tools ?? assistant.tools),
180
+ tools: request.tools ?? assistant.tools,
137
181
  metadata: request.metadata ?? {},
138
182
  usage: null,
139
183
  temperature: request.temperature ?? assistant.temperature,
140
- top_p: request.top_p,
184
+ top_p: request.top_p ?? assistant.top_p,
141
185
  max_prompt_tokens: request.max_prompt_tokens,
142
186
  max_completion_tokens: request.max_completion_tokens,
143
187
  truncation_strategy: request.truncation_strategy,
144
- response_format: request.response_format,
188
+ response_format: request.response_format ?? assistant.response_format,
145
189
  tool_choice: request.tool_choice,
146
190
  parallel_tool_calls: request.parallel_tool_calls,
147
191
  };
148
- const abortController = new AbortController();
149
- const streamEmitter = request.stream ? new EventEmitter() : undefined;
150
- this.runs.set(runId, { run, abortController, streamEmitter });
151
- if (request.additional_messages) {
152
- for (const msg of request.additional_messages) {
192
+ const state = {
193
+ run,
194
+ abortController: new AbortController(),
195
+ emitter: new EventEmitter(),
196
+ events: [],
197
+ ended: false,
198
+ pendingCalls: [],
199
+ flushScheduled: false,
200
+ };
201
+ this.runs.set(runId, state);
202
+ this.activeRunByThread.set(threadId, runId);
203
+ this.evictFinishedRuns();
204
+ try {
205
+ for (const msg of request.additional_messages ?? []) {
153
206
  await this.threadManager.addMessage(threadId, msg);
154
207
  }
155
208
  }
156
- streamEmitter?.emit('event', 'thread.run.created', run);
157
- streamEmitter?.emit('event', 'thread.run.queued', run);
158
- this.executeRun(runId, threadId, assistant, request, !!request.stream).catch((error) => {
159
- const errorMessage = error instanceof Error ? error.message : String(error);
160
- console.warn(`[OpenAIAdapter] Run ${runId} failed:`, errorMessage);
161
- const state = this.runs.get(runId);
162
- if (state) {
163
- state.run.status = 'failed';
164
- state.run.failed_at = Math.floor(Date.now() / 1000);
165
- state.run.last_error = {
166
- code: 'server_error',
167
- message: errorMessage,
168
- };
169
- state.streamEmitter?.emit('event', 'thread.run.failed', state.run);
170
- state.streamEmitter?.emit('end');
171
- }
172
- });
173
- return run;
209
+ catch (error) {
210
+ this.runs.delete(runId);
211
+ this.activeRunByThread.delete(threadId);
212
+ throw error;
213
+ }
214
+ this.emit(state, 'thread.run.created', run);
215
+ this.emit(state, 'thread.run.queued', run);
216
+ void this.executeRun(state, assistant, request);
217
+ return structuredClone(run);
174
218
  }
175
219
  /**
176
220
  * Get a run by ID
@@ -178,25 +222,38 @@ export class OpenAIAdapter {
178
222
  getRun(threadId, runId) {
179
223
  const state = this.runs.get(runId);
180
224
  if (state?.run.thread_id === threadId) {
181
- return state.run;
225
+ return structuredClone(state.run);
182
226
  }
183
227
  return undefined;
184
228
  }
185
229
  /**
186
- * Cancel a run
230
+ * List runs of a thread, newest first
231
+ */
232
+ listRuns(threadId) {
233
+ return Array.from(this.runs.values())
234
+ .filter((state) => state.run.thread_id === threadId)
235
+ .map((state) => structuredClone(state.run))
236
+ .reverse();
237
+ }
238
+ /**
239
+ * Cancel a run. Throws when the run already finished.
187
240
  */
188
241
  cancelRun(threadId, runId) {
189
242
  const state = this.runs.get(runId);
190
- if (state?.run.thread_id === threadId) {
191
- state.abortController.abort();
192
- state.run.status = 'cancelled';
193
- state.run.cancelled_at = Math.floor(Date.now() / 1000);
194
- return state.run;
243
+ if (state?.run.thread_id !== threadId) {
244
+ return undefined;
195
245
  }
196
- return undefined;
246
+ if (!isActive(state.run.status) || state.run.status === 'cancelling') {
247
+ throw new Error(`Cannot cancel run with status '${state.run.status}'.`);
248
+ }
249
+ state.run.status = 'cancelling';
250
+ this.emit(state, 'thread.run.cancelling', state.run);
251
+ this.terminate(state, new RunTerminatedError('cancelled'));
252
+ return structuredClone(state.run);
197
253
  }
198
254
  /**
199
- * Submit tool outputs for a run that requires action
255
+ * Submit outputs for the tool calls of a run that requires action.
256
+ * Outputs for every pending tool call must be provided.
200
257
  */
201
258
  async submitToolOutputs(threadId, runId, request) {
202
259
  const state = this.runs.get(runId);
@@ -204,53 +261,265 @@ export class OpenAIAdapter {
204
261
  return undefined;
205
262
  }
206
263
  if (state.run.status !== 'requires_action') {
207
- throw new Error('Run is not waiting for tool outputs');
264
+ throw new Error(`Run is not waiting for tool outputs (status: '${state.run.status}').`);
208
265
  }
209
- if (!state.toolOutputs) {
210
- state.toolOutputs = new Map();
266
+ const outputs = new Map((request.tool_outputs ?? []).map((output) => [output.tool_call_id, output.output]));
267
+ const missing = state.pendingCalls.filter((pending) => !outputs.has(pending.call.id));
268
+ if (missing.length > 0) {
269
+ throw new Error(`Missing tool outputs for tool calls: ${missing.map((p) => p.call.id).join(', ')}`);
211
270
  }
212
- for (const output of request.tool_outputs) {
213
- state.toolOutputs.set(output.tool_call_id, output.output);
271
+ const unknown = [...outputs.keys()].filter((id) => !state.pendingCalls.some((pending) => pending.call.id === id));
272
+ if (unknown.length > 0) {
273
+ throw new Error(`Unknown tool call ids: ${unknown.join(', ')}`);
214
274
  }
275
+ const pending = state.pendingCalls;
276
+ state.pendingCalls = [];
277
+ this.clearExpiry(state);
215
278
  state.run.status = 'in_progress';
216
279
  state.run.required_action = null;
217
- return state.run;
280
+ this.emit(state, 'thread.run.in_progress', state.run);
281
+ for (const call of pending) {
282
+ call.resolve(outputs.get(call.call.id) ?? '');
283
+ }
284
+ return structuredClone(state.run);
218
285
  }
219
286
  /**
220
- * Get the stream emitter for a streaming run
287
+ * Get the event emitter of a run (all runs have one)
221
288
  */
222
289
  getStreamEmitter(runId) {
223
- return this.runs.get(runId)?.streamEmitter;
290
+ return this.runs.get(runId)?.emitter;
291
+ }
292
+ /**
293
+ * Position in the run's event log; pass it to {@link streamRunEvents} to
294
+ * stream only events emitted after this point.
295
+ */
296
+ getRunEventCursor(runId) {
297
+ return this.runs.get(runId)?.events.length ?? 0;
224
298
  }
225
- async executeRun(runId, threadId, assistant, request, streaming = false) {
299
+ /**
300
+ * Stream run events as server-sent-event payloads, starting at `fromIndex`
301
+ * of the run's event log (0 replays the whole run). The stream ends after the
302
+ * next `done` event, which follows a terminal status or `requires_action`.
303
+ */
304
+ async *streamRunEvents(runId, fromIndex = 0) {
226
305
  const state = this.runs.get(runId);
227
306
  if (!state)
228
307
  return;
229
- const emitter = state.streamEmitter;
230
- state.run.status = 'in_progress';
231
- state.run.started_at = Math.floor(Date.now() / 1000);
232
- emitter?.emit('event', 'thread.run.in_progress', state.run);
308
+ let index = fromIndex;
309
+ let wake = null;
310
+ const onEvent = () => {
311
+ if (wake) {
312
+ wake();
313
+ wake = null;
314
+ }
315
+ };
316
+ state.emitter.on('event', onEvent);
317
+ state.emitter.on('end', onEvent);
233
318
  try {
234
- const messages = await this.threadManager.getMessagesForLLM(threadId);
319
+ while (true) {
320
+ if (index < state.events.length) {
321
+ const event = state.events[index++];
322
+ yield event;
323
+ if (event.event === 'done')
324
+ return;
325
+ continue;
326
+ }
327
+ if (state.ended)
328
+ return;
329
+ await new Promise((resolve) => {
330
+ wake = resolve;
331
+ });
332
+ }
333
+ }
334
+ finally {
335
+ state.emitter.off('event', onEvent);
336
+ state.emitter.off('end', onEvent);
337
+ }
338
+ }
339
+ emit(state, event, data) {
340
+ const payload = {
341
+ event,
342
+ data: typeof data === 'string' ? data : structuredClone(data),
343
+ };
344
+ state.events.push(payload);
345
+ state.emitter.emit('event', payload.event, payload.data);
346
+ }
347
+ end(state) {
348
+ if (state.ended)
349
+ return;
350
+ state.ended = true;
351
+ this.clearExpiry(state);
352
+ if (this.activeRunByThread.get(state.run.thread_id) === state.run.id) {
353
+ this.activeRunByThread.delete(state.run.thread_id);
354
+ }
355
+ state.emitter.emit('end');
356
+ }
357
+ clearExpiry(state) {
358
+ if (state.expiryTimer !== undefined) {
359
+ clearTimeout(state.expiryTimer);
360
+ state.expiryTimer = undefined;
361
+ }
362
+ }
363
+ terminate(state, reason) {
364
+ this.clearExpiry(state);
365
+ const pending = state.pendingCalls;
366
+ state.pendingCalls = [];
367
+ for (const call of pending)
368
+ call.reject(reason);
369
+ state.abortController.abort(reason);
370
+ }
371
+ evictFinishedRuns() {
372
+ if (this.runs.size <= this.maxStoredRuns)
373
+ return;
374
+ for (const [id, state] of this.runs) {
375
+ if (this.runs.size <= this.maxStoredRuns)
376
+ return;
377
+ if (state.ended)
378
+ this.runs.delete(id);
379
+ }
380
+ }
381
+ resolveModel(model) {
382
+ if (model !== COGITATOR_MODEL_ID)
383
+ return model;
384
+ if (!this.defaultModel) {
385
+ throw new Error(`Model '${COGITATOR_MODEL_ID}' requires the server to be configured with a defaultModel`);
386
+ }
387
+ return this.defaultModel;
388
+ }
389
+ /**
390
+ * Turn assistant `function` tools into Cogitator tools whose execution is
391
+ * delegated to the API client through `requires_action`.
392
+ */
393
+ createClientTools(state, definitions) {
394
+ return definitions.map((definition) => {
395
+ const parameters = toZodParameters(definition.parameters);
396
+ return {
397
+ name: definition.name,
398
+ description: definition.description ?? definition.name,
399
+ parameters,
400
+ execute: (args) => new Promise((resolve, reject) => {
401
+ if (state.abortController.signal.aborted) {
402
+ reject(new RunTerminatedError('cancelled'));
403
+ return;
404
+ }
405
+ state.pendingCalls.push({
406
+ call: {
407
+ id: `call_${nanoid()}`,
408
+ type: 'function',
409
+ function: { name: definition.name, arguments: JSON.stringify(args ?? {}) },
410
+ },
411
+ resolve,
412
+ reject,
413
+ });
414
+ this.scheduleRequiresAction(state);
415
+ }),
416
+ toJSON: () => {
417
+ const schema = (definition.parameters ?? {});
418
+ return {
419
+ name: definition.name,
420
+ description: definition.description ?? definition.name,
421
+ parameters: {
422
+ type: 'object',
423
+ properties: schema.properties ?? {},
424
+ required: schema.required,
425
+ },
426
+ };
427
+ },
428
+ };
429
+ });
430
+ }
431
+ scheduleRequiresAction(state) {
432
+ if (state.flushScheduled)
433
+ return;
434
+ state.flushScheduled = true;
435
+ setImmediate(() => {
436
+ state.flushScheduled = false;
437
+ if (state.pendingCalls.length === 0 || state.abortController.signal.aborted)
438
+ return;
439
+ state.run.status = 'requires_action';
440
+ state.run.required_action = {
441
+ type: 'submit_tool_outputs',
442
+ submit_tool_outputs: { tool_calls: state.pendingCalls.map((pending) => pending.call) },
443
+ };
444
+ state.run.expires_at = nowSeconds() + RUN_TTL_SECONDS;
445
+ this.emit(state, 'thread.run.requires_action', state.run);
446
+ this.emit(state, 'done', '[DONE]');
447
+ this.clearExpiry(state);
448
+ state.expiryTimer = setTimeout(() => {
449
+ this.terminate(state, new RunTerminatedError('expired'));
450
+ }, RUN_TTL_SECONDS * 1000);
451
+ });
452
+ }
453
+ async buildRunInput(threadId, request) {
454
+ let messages = await this.threadManager.getMessagesForLLM(threadId);
455
+ const truncation = request.truncation_strategy;
456
+ if (truncation?.type === 'last_messages' && truncation.last_messages) {
457
+ messages = messages.slice(-truncation.last_messages);
458
+ }
459
+ let lastUserIndex = -1;
460
+ for (let i = messages.length - 1; i >= 0; i--) {
461
+ if (messages[i].role === 'user') {
462
+ lastUserIndex = i;
463
+ break;
464
+ }
465
+ }
466
+ if (lastUserIndex === -1) {
467
+ throw new Error('No user message found');
468
+ }
469
+ const current = messages[lastUserIndex];
470
+ const prior = messages.slice(0, lastUserIndex);
471
+ const input = prior.length > 0
472
+ ? `Conversation so far:\n\n${renderTranscript(prior)}\n\nUser: ${current.content}`
473
+ : current.content;
474
+ return { input, images: current.images, hasHistory: prior.length > 0 };
475
+ }
476
+ async executeRun(state, assistant, request) {
477
+ const { run } = state;
478
+ const threadId = run.thread_id;
479
+ const signal = state.abortController.signal;
480
+ try {
481
+ run.status = 'in_progress';
482
+ run.started_at = nowSeconds();
483
+ this.emit(state, 'thread.run.in_progress', run);
484
+ const { input, images, hasHistory } = await this.buildRunInput(threadId, request);
485
+ const serverToolNames = new Set(this.tools.map((t) => t.name));
486
+ const functionDefinitions = run.tools.flatMap((t) => t.type === 'function' && !serverToolNames.has(t.function.name) ? [t.function] : []);
487
+ let tools = [...this.tools, ...this.createClientTools(state, functionDefinitions)];
488
+ const toolChoice = run.tool_choice;
489
+ if (toolChoice === 'none') {
490
+ tools = [];
491
+ }
492
+ else if (typeof toolChoice === 'object') {
493
+ tools = tools.filter((t) => t.name === toolChoice.function.name);
494
+ }
495
+ const format = toAgentResponseFormat(run.response_format);
496
+ const instructions = [
497
+ run.instructions ?? '',
498
+ request.additional_instructions ?? '',
499
+ format.instructions ?? '',
500
+ ]
501
+ .filter((part) => part.length > 0)
502
+ .join('\n\n');
235
503
  const agent = new Agent({
236
504
  name: assistant.name ?? 'assistant',
237
- model: request.model ?? assistant.model,
238
- instructions: request.instructions ?? assistant.instructions ?? '',
239
- temperature: request.temperature ?? assistant.temperature,
240
- tools: this.tools,
505
+ model: this.resolveModel(run.model),
506
+ instructions,
507
+ temperature: run.temperature,
508
+ topP: run.top_p,
509
+ maxTokens: run.max_completion_tokens,
510
+ responseFormat: format.responseFormat,
511
+ tools,
241
512
  });
242
- const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
243
- if (!lastUserMessage) {
244
- throw new Error('No user message found');
245
- }
246
513
  const messageId = `msg_${nanoid()}`;
247
- let accumulatedContent = '';
248
- let deltaIndex = 0;
249
- if (streaming && emitter) {
250
- const inProgressMessage = {
514
+ let messageAnnounced = false;
515
+ const announceMessage = () => {
516
+ if (messageAnnounced)
517
+ return;
518
+ messageAnnounced = true;
519
+ const inProgress = {
251
520
  id: messageId,
252
521
  object: 'thread.message',
253
- created_at: Math.floor(Date.now() / 1000),
522
+ created_at: nowSeconds(),
254
523
  thread_id: threadId,
255
524
  status: 'in_progress',
256
525
  completed_at: null,
@@ -258,75 +527,85 @@ export class OpenAIAdapter {
258
527
  role: 'assistant',
259
528
  content: [],
260
529
  assistant_id: assistant.id,
261
- run_id: runId,
530
+ run_id: run.id,
262
531
  attachments: [],
263
532
  metadata: {},
264
533
  };
265
- emitter.emit('event', 'thread.message.created', inProgressMessage);
266
- emitter.emit('event', 'thread.message.in_progress', inProgressMessage);
267
- }
534
+ this.emit(state, 'thread.message.created', inProgress);
535
+ this.emit(state, 'thread.message.in_progress', inProgress);
536
+ };
537
+ let accumulated = '';
268
538
  const result = await this.cogitator.run(agent, {
269
- input: lastUserMessage.content,
539
+ input,
540
+ images,
270
541
  threadId,
271
- stream: streaming,
272
- onToken: streaming
273
- ? (token) => {
274
- accumulatedContent += token;
275
- if (emitter) {
276
- const delta = {
277
- id: messageId,
278
- object: 'thread.message.delta',
279
- delta: {
280
- content: [
281
- {
282
- index: deltaIndex++,
283
- type: 'text',
284
- text: { value: token },
285
- },
286
- ],
287
- },
288
- };
289
- emitter.emit('event', 'thread.message.delta', delta);
290
- }
291
- }
292
- : undefined,
542
+ signal,
543
+ stream: !!request.stream,
544
+ parallelToolCalls: run.parallel_tool_calls,
545
+ ...(hasHistory && { loadHistory: false }),
546
+ onToken: (token) => {
547
+ if (signal.aborted)
548
+ return;
549
+ announceMessage();
550
+ accumulated += token;
551
+ const delta = {
552
+ id: messageId,
553
+ object: 'thread.message.delta',
554
+ delta: { content: [{ index: 0, type: 'text', text: { value: token } }] },
555
+ };
556
+ this.emit(state, 'thread.message.delta', delta);
557
+ },
293
558
  });
294
- if (state.abortController.signal.aborted) {
295
- return;
559
+ if (signal.aborted) {
560
+ throw signal.reason instanceof Error ? signal.reason : new RunTerminatedError('cancelled');
296
561
  }
297
- const finalContent = result.output || accumulatedContent;
562
+ const finalContent = result.output || accumulated;
298
563
  if (finalContent) {
299
- const message = await this.threadManager.addAssistantMessage(threadId, finalContent, assistant.id, runId);
300
- if (streaming && emitter && message) {
301
- emitter.emit('event', 'thread.message.completed', message);
564
+ announceMessage();
565
+ const message = await this.threadManager.addAssistantMessage(threadId, finalContent, assistant.id, run.id, messageId);
566
+ if (message) {
567
+ this.emit(state, 'thread.message.completed', message);
302
568
  }
303
569
  }
304
- state.run.status = 'completed';
305
- state.run.completed_at = Math.floor(Date.now() / 1000);
306
- state.run.usage = result.usage
570
+ run.status = 'completed';
571
+ run.completed_at = nowSeconds();
572
+ run.usage = result.usage
307
573
  ? {
308
574
  prompt_tokens: result.usage.inputTokens,
309
575
  completion_tokens: result.usage.outputTokens,
310
576
  total_tokens: result.usage.totalTokens,
311
577
  }
312
578
  : null;
313
- emitter?.emit('event', 'thread.run.completed', state.run);
314
- emitter?.emit('event', 'done', '[DONE]');
315
- emitter?.emit('end');
579
+ this.emit(state, 'thread.run.completed', run);
316
580
  }
317
581
  catch (error) {
318
- if (state.abortController.signal.aborted) {
319
- return;
582
+ const reason = signal.aborted ? signal.reason : error;
583
+ if (reason instanceof RunTerminatedError) {
584
+ if (reason.status === 'cancelled') {
585
+ run.status = 'cancelled';
586
+ run.cancelled_at = nowSeconds();
587
+ this.emit(state, 'thread.run.cancelled', run);
588
+ }
589
+ else {
590
+ run.status = 'expired';
591
+ this.emit(state, 'thread.run.expired', run);
592
+ }
320
593
  }
321
- state.run.status = 'failed';
322
- state.run.failed_at = Math.floor(Date.now() / 1000);
323
- state.run.last_error = {
324
- code: 'server_error',
325
- message: error instanceof Error ? error.message : String(error),
326
- };
327
- emitter?.emit('event', 'thread.run.failed', state.run);
328
- emitter?.emit('error', error instanceof Error ? error : new Error(String(error)));
329
- emitter?.emit('end');
594
+ else {
595
+ run.status = 'failed';
596
+ run.failed_at = nowSeconds();
597
+ run.last_error = {
598
+ code: 'server_error',
599
+ message: error instanceof Error ? error.message : String(error),
600
+ };
601
+ this.emit(state, 'thread.run.failed', run);
602
+ }
603
+ }
604
+ finally {
605
+ run.required_action = null;
606
+ this.terminate(state, new RunTerminatedError('cancelled'));
607
+ this.emit(state, 'done', '[DONE]');
608
+ this.end(state);
330
609
  }
331
610
  }
332
611
  }