@rcrsr/rill-ext-openai 0.8.3 → 0.8.4

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/dist/factory.js DELETED
@@ -1,768 +0,0 @@
1
- /**
2
- * Extension factory for OpenAI API integration.
3
- * Creates extension instance with config validation and SDK lifecycle management.
4
- */
5
- import OpenAI from 'openai';
6
- import { RuntimeError, emitExtensionEvent, createVector, isCallable, isVector, } from '@rcrsr/rill';
7
- import { validateApiKey, validateModel, validateTemperature, validateEmbedText, validateEmbedModel, validateEmbedBatch, mapProviderError, executeToolLoop, } from '@rcrsr/rill-ext-llm-shared';
8
- // ============================================================
9
- // CONSTANTS
10
- // ============================================================
11
- const DEFAULT_MAX_TOKENS = 4096;
12
- // ============================================================
13
- // ERROR DETECTION
14
- // ============================================================
15
- /**
16
- * OpenAI-specific error detector for mapProviderError.
17
- * Extracts status code and message from OpenAI.APIError instances.
18
- *
19
- * @param error - Error to detect
20
- * @returns Status and message if OpenAI error, null otherwise
21
- */
22
- const detectOpenAIError = (error) => {
23
- if (error instanceof OpenAI.APIError) {
24
- return {
25
- status: error.status ?? undefined,
26
- message: error.message,
27
- };
28
- }
29
- return null;
30
- };
31
- // ============================================================
32
- // FACTORY
33
- // ============================================================
34
- /**
35
- * Create OpenAI extension instance.
36
- * Validates configuration and returns host functions with cleanup.
37
- *
38
- * @param config - Extension configuration
39
- * @returns ExtensionResult with message, messages, embed, embed_batch, tool_loop and dispose
40
- * @throws Error for invalid configuration (EC-1 through EC-4)
41
- *
42
- * @example
43
- * ```typescript
44
- * const ext = createOpenAIExtension({
45
- * api_key: process.env.OPENAI_API_KEY,
46
- * model: 'gpt-4-turbo',
47
- * temperature: 0.7
48
- * });
49
- * // Use with rill runtime...
50
- * await ext.dispose();
51
- * ```
52
- */
53
- export function createOpenAIExtension(config) {
54
- // Validate required fields (§4.1)
55
- validateApiKey(config.api_key);
56
- validateModel(config.model);
57
- validateTemperature(config.temperature);
58
- // Instantiate SDK client at factory time (§4.1)
59
- // Note: will be used in tasks 3.3 and 3.4 for actual function implementations
60
- const client = new OpenAI({
61
- apiKey: config.api_key,
62
- baseURL: config.base_url,
63
- maxRetries: config.max_retries,
64
- timeout: config.timeout,
65
- });
66
- // Extract config values for use in functions
67
- const factoryModel = config.model;
68
- const factoryTemperature = config.temperature;
69
- const factoryMaxTokens = config.max_tokens ?? DEFAULT_MAX_TOKENS;
70
- const factorySystem = config.system;
71
- const factoryEmbedModel = config.embed_model;
72
- // Suppress unused variable warnings for values used in task 3.4
73
- void factoryEmbedModel;
74
- // AbortController for cancelling pending requests (§4.9, IR-11)
75
- let abortController = new AbortController();
76
- // Dispose function for cleanup (§4.9)
77
- const dispose = async () => {
78
- // AC-28: Idempotent cleanup, try-catch each step
79
- try {
80
- // Cancel pending API requests via AbortController (IR-11)
81
- if (abortController) {
82
- abortController.abort();
83
- abortController = undefined;
84
- }
85
- }
86
- catch (error) {
87
- const message = error instanceof Error ? error.message : 'Unknown error';
88
- console.warn(`Failed to abort OpenAI requests: ${message}`);
89
- }
90
- try {
91
- // Cleanup SDK HTTP connections
92
- // Note: OpenAI SDK doesn't expose a close() method, but we include
93
- // this structure for consistency with extension pattern
94
- }
95
- catch (error) {
96
- const message = error instanceof Error ? error.message : 'Unknown error';
97
- console.warn(`Failed to cleanup OpenAI SDK: ${message}`);
98
- }
99
- };
100
- // Return extension result with implementations
101
- const result = {
102
- // IR-4: openai::message
103
- message: {
104
- params: [
105
- { name: 'text', type: 'string' },
106
- { name: 'options', type: 'dict', defaultValue: {} },
107
- ],
108
- fn: async (args, ctx) => {
109
- const startTime = Date.now();
110
- try {
111
- // Extract arguments
112
- const text = args[0];
113
- const options = (args[1] ?? {});
114
- // EC-5: Validate text is non-empty
115
- if (text.trim().length === 0) {
116
- throw new RuntimeError('RILL-R004', 'prompt text cannot be empty');
117
- }
118
- // Extract options
119
- const system = typeof options['system'] === 'string'
120
- ? options['system']
121
- : factorySystem;
122
- const maxTokens = typeof options['max_tokens'] === 'number'
123
- ? options['max_tokens']
124
- : factoryMaxTokens;
125
- // Build messages array (OpenAI uses system as first message, not separate param)
126
- const apiMessages = [];
127
- if (system !== undefined) {
128
- apiMessages.push({
129
- role: 'system',
130
- content: system,
131
- });
132
- }
133
- apiMessages.push({
134
- role: 'user',
135
- content: text,
136
- });
137
- // Call OpenAI API
138
- const apiParams = {
139
- model: factoryModel,
140
- max_tokens: maxTokens,
141
- messages: apiMessages,
142
- };
143
- // Add optional parameters only if defined
144
- if (factoryTemperature !== undefined) {
145
- apiParams.temperature = factoryTemperature;
146
- }
147
- const response = await client.chat.completions.create(apiParams);
148
- // Extract text content from response (§4.2: choices[0].message.content)
149
- const content = response.choices[0]?.message?.content ?? '';
150
- // Build normalized response dict (§3.2)
151
- const result = {
152
- content,
153
- model: response.model,
154
- usage: {
155
- input: response.usage?.prompt_tokens ?? 0,
156
- output: response.usage?.completion_tokens ?? 0,
157
- },
158
- stop_reason: response.choices[0]?.finish_reason ?? 'unknown',
159
- id: response.id,
160
- messages: [
161
- ...(system ? [{ role: 'system', content: system }] : []),
162
- { role: 'user', content: text },
163
- { role: 'assistant', content },
164
- ],
165
- };
166
- // Emit success event (§4.10)
167
- const duration = Date.now() - startTime;
168
- emitExtensionEvent(ctx, {
169
- event: 'openai:message',
170
- subsystem: 'extension:openai',
171
- duration,
172
- model: response.model,
173
- usage: result.usage,
174
- });
175
- return result;
176
- }
177
- catch (error) {
178
- // Map error and emit failure event
179
- const duration = Date.now() - startTime;
180
- const rillError = mapProviderError('OpenAI', error, detectOpenAIError);
181
- emitExtensionEvent(ctx, {
182
- event: 'openai:error',
183
- subsystem: 'extension:openai',
184
- error: rillError.message,
185
- duration,
186
- });
187
- throw rillError;
188
- }
189
- },
190
- description: 'Send single message to OpenAI API',
191
- returnType: 'dict',
192
- },
193
- // IR-5: openai::messages
194
- messages: {
195
- params: [
196
- { name: 'messages', type: 'list' },
197
- { name: 'options', type: 'dict', defaultValue: {} },
198
- ],
199
- fn: async (args, ctx) => {
200
- const startTime = Date.now();
201
- try {
202
- // Extract arguments
203
- const messages = args[0];
204
- const options = (args[1] ?? {});
205
- // AC-23: Empty messages list raises error
206
- if (messages.length === 0) {
207
- throw new RuntimeError('RILL-R004', 'messages list cannot be empty');
208
- }
209
- // Extract options
210
- const system = typeof options['system'] === 'string'
211
- ? options['system']
212
- : factorySystem;
213
- const maxTokens = typeof options['max_tokens'] === 'number'
214
- ? options['max_tokens']
215
- : factoryMaxTokens;
216
- // Build messages array (OpenAI uses system as first message)
217
- const apiMessages = [];
218
- if (system !== undefined) {
219
- apiMessages.push({
220
- role: 'system',
221
- content: system,
222
- });
223
- }
224
- // Validate and transform messages
225
- for (let i = 0; i < messages.length; i++) {
226
- const msg = messages[i];
227
- // EC-10: Missing role raises error
228
- if (!msg || typeof msg !== 'object' || !('role' in msg)) {
229
- throw new RuntimeError('RILL-R004', "message missing required 'role' field");
230
- }
231
- const role = msg['role'];
232
- // EC-11: Unknown role value raises error
233
- if (role !== 'user' && role !== 'assistant' && role !== 'tool') {
234
- throw new RuntimeError('RILL-R004', `invalid role '${role}'`);
235
- }
236
- // EC-12: User message missing content
237
- if (role === 'user' || role === 'tool') {
238
- if (!('content' in msg) || typeof msg['content'] !== 'string') {
239
- throw new RuntimeError('RILL-R004', `${role} message requires 'content'`);
240
- }
241
- apiMessages.push({
242
- role: role,
243
- content: msg['content'],
244
- });
245
- }
246
- // EC-13: Assistant missing both content and tool_calls
247
- else if (role === 'assistant') {
248
- const hasContent = 'content' in msg && msg['content'];
249
- const hasToolCalls = 'tool_calls' in msg && msg['tool_calls'];
250
- if (!hasContent && !hasToolCalls) {
251
- throw new RuntimeError('RILL-R004', "assistant message requires 'content' or 'tool_calls'");
252
- }
253
- // For now, we only support content
254
- if (hasContent) {
255
- apiMessages.push({
256
- role: 'assistant',
257
- content: msg['content'],
258
- });
259
- }
260
- }
261
- }
262
- // Call OpenAI API
263
- const apiParams = {
264
- model: factoryModel,
265
- max_tokens: maxTokens,
266
- messages: apiMessages,
267
- };
268
- // Add optional parameters only if defined
269
- if (factoryTemperature !== undefined) {
270
- apiParams.temperature = factoryTemperature;
271
- }
272
- const response = await client.chat.completions.create(apiParams);
273
- // Extract text content from response
274
- const content = response.choices[0]?.message?.content ?? '';
275
- // Build full conversation history (§3.2)
276
- const fullMessages = [
277
- ...messages.map((m) => {
278
- const normalized = { role: m['role'] };
279
- if ('content' in m)
280
- normalized['content'] = m['content'];
281
- if ('tool_calls' in m)
282
- normalized['tool_calls'] = m['tool_calls'];
283
- return normalized;
284
- }),
285
- { role: 'assistant', content },
286
- ];
287
- // Build normalized response dict (§3.2)
288
- const result = {
289
- content,
290
- model: response.model,
291
- usage: {
292
- input: response.usage?.prompt_tokens ?? 0,
293
- output: response.usage?.completion_tokens ?? 0,
294
- },
295
- stop_reason: response.choices[0]?.finish_reason ?? 'unknown',
296
- id: response.id,
297
- messages: fullMessages,
298
- };
299
- // Emit success event (§4.10)
300
- const duration = Date.now() - startTime;
301
- emitExtensionEvent(ctx, {
302
- event: 'openai:messages',
303
- subsystem: 'extension:openai',
304
- duration,
305
- model: response.model,
306
- usage: result.usage,
307
- });
308
- return result;
309
- }
310
- catch (error) {
311
- // Map error and emit failure event
312
- const duration = Date.now() - startTime;
313
- const rillError = mapProviderError('OpenAI', error, detectOpenAIError);
314
- emitExtensionEvent(ctx, {
315
- event: 'openai:error',
316
- subsystem: 'extension:openai',
317
- error: rillError.message,
318
- duration,
319
- });
320
- throw rillError;
321
- }
322
- },
323
- description: 'Send multi-turn conversation to OpenAI API',
324
- returnType: 'dict',
325
- },
326
- // IR-6: openai::embed
327
- embed: {
328
- params: [{ name: 'text', type: 'string' }],
329
- fn: async (args, ctx) => {
330
- const startTime = Date.now();
331
- try {
332
- // Extract arguments
333
- const text = args[0];
334
- // EC-15: Validate text is non-empty
335
- validateEmbedText(text.trim());
336
- // EC-16: Validate embed_model is configured
337
- validateEmbedModel(factoryEmbedModel);
338
- // Call OpenAI embeddings API
339
- const response = await client.embeddings.create({
340
- model: factoryEmbedModel,
341
- input: text,
342
- encoding_format: 'float',
343
- });
344
- // Extract embedding data
345
- const embeddingData = response.data[0]?.embedding;
346
- if (!embeddingData || embeddingData.length === 0) {
347
- throw new RuntimeError('RILL-R004', 'OpenAI: empty embedding returned');
348
- }
349
- // Convert to Float32Array and create RillVector
350
- const float32Data = new Float32Array(embeddingData);
351
- const vector = createVector(float32Data, factoryEmbedModel);
352
- // Emit success event (§4.10)
353
- const duration = Date.now() - startTime;
354
- emitExtensionEvent(ctx, {
355
- event: 'openai:embed',
356
- subsystem: 'extension:openai',
357
- duration,
358
- model: factoryEmbedModel,
359
- dimensions: float32Data.length,
360
- });
361
- return vector;
362
- }
363
- catch (error) {
364
- // Map error and emit failure event
365
- const duration = Date.now() - startTime;
366
- const rillError = mapProviderError('OpenAI', error, detectOpenAIError);
367
- emitExtensionEvent(ctx, {
368
- event: 'openai:error',
369
- subsystem: 'extension:openai',
370
- error: rillError.message,
371
- duration,
372
- });
373
- throw rillError;
374
- }
375
- },
376
- description: 'Generate embedding vector for text',
377
- returnType: 'vector',
378
- },
379
- // IR-7: openai::embed_batch
380
- embed_batch: {
381
- params: [{ name: 'texts', type: 'list' }],
382
- fn: async (args, ctx) => {
383
- const startTime = Date.now();
384
- try {
385
- // Extract arguments
386
- const texts = args[0];
387
- // AC-24: Empty list returns empty list
388
- if (texts.length === 0) {
389
- return [];
390
- }
391
- // EC-17: Validate embed_model is configured
392
- validateEmbedModel(factoryEmbedModel);
393
- // EC-18: Validate all elements are strings
394
- const stringTexts = validateEmbedBatch(texts);
395
- // Call OpenAI embeddings API with batch
396
- const response = await client.embeddings.create({
397
- model: factoryEmbedModel,
398
- input: stringTexts,
399
- encoding_format: 'float',
400
- });
401
- // Convert embeddings to RillVector list
402
- const vectors = [];
403
- for (const embeddingItem of response.data) {
404
- const embeddingData = embeddingItem.embedding;
405
- if (!embeddingData || embeddingData.length === 0) {
406
- throw new RuntimeError('RILL-R004', 'OpenAI: empty embedding returned');
407
- }
408
- const float32Data = new Float32Array(embeddingData);
409
- const vector = createVector(float32Data, factoryEmbedModel);
410
- vectors.push(vector);
411
- }
412
- // Emit success event (§4.10)
413
- const duration = Date.now() - startTime;
414
- const firstVector = vectors[0];
415
- const dimensions = firstVector && isVector(firstVector) ? firstVector.data.length : 0;
416
- emitExtensionEvent(ctx, {
417
- event: 'openai:embed_batch',
418
- subsystem: 'extension:openai',
419
- duration,
420
- model: factoryEmbedModel,
421
- dimensions,
422
- count: vectors.length,
423
- });
424
- return vectors;
425
- }
426
- catch (error) {
427
- // Map error and emit failure event
428
- const duration = Date.now() - startTime;
429
- const rillError = mapProviderError('OpenAI', error, detectOpenAIError);
430
- emitExtensionEvent(ctx, {
431
- event: 'openai:error',
432
- subsystem: 'extension:openai',
433
- error: rillError.message,
434
- duration,
435
- });
436
- throw rillError;
437
- }
438
- },
439
- description: 'Generate embedding vectors for multiple texts',
440
- returnType: 'list',
441
- },
442
- // IR-8: openai::tool_loop
443
- tool_loop: {
444
- params: [
445
- { name: 'prompt', type: 'string' },
446
- { name: 'options', type: 'dict', defaultValue: {} },
447
- ],
448
- fn: async (args, ctx) => {
449
- const startTime = Date.now();
450
- try {
451
- // Extract arguments
452
- const prompt = args[0];
453
- const options = (args[1] ?? {});
454
- // EC-20: Validate prompt is non-empty
455
- if (prompt.trim().length === 0) {
456
- throw new RuntimeError('RILL-R004', 'prompt text cannot be empty');
457
- }
458
- // EC-21: Validate tools option is present
459
- if (!('tools' in options) || !Array.isArray(options['tools'])) {
460
- throw new RuntimeError('RILL-R004', "tool_loop requires 'tools' option");
461
- }
462
- const toolDescriptors = options['tools'];
463
- // Convert tool descriptors array to dict for shared tool loop
464
- const toolsDict = {};
465
- for (const descriptor of toolDescriptors) {
466
- const name = typeof descriptor['name'] === 'string'
467
- ? descriptor['name']
468
- : null;
469
- if (!name) {
470
- throw new RuntimeError('RILL-R004', 'tool descriptor missing name');
471
- }
472
- const toolFnValue = descriptor['fn'];
473
- if (!toolFnValue) {
474
- throw new RuntimeError('RILL-R004', `tool '${name}' missing fn property`);
475
- }
476
- // Validate tool is callable
477
- if (!isCallable(toolFnValue)) {
478
- throw new RuntimeError('RILL-R004', `tool '${name}' fn must be callable`);
479
- }
480
- // Extract params metadata from descriptor and enhance callable
481
- const paramsObj = descriptor['params'];
482
- const description = typeof descriptor['description'] === 'string'
483
- ? descriptor['description']
484
- : '';
485
- let enhancedCallable = toolFnValue;
486
- if (paramsObj &&
487
- typeof paramsObj === 'object' &&
488
- !Array.isArray(paramsObj)) {
489
- // Convert params object to CallableParam[] format
490
- const params = Object.entries(paramsObj).map(([paramName, paramMeta]) => {
491
- const meta = paramMeta;
492
- const typeStr = typeof meta['type'] === 'string' ? meta['type'] : null;
493
- // Map type string to RillTypeName
494
- let typeName = null;
495
- if (typeStr === 'string')
496
- typeName = 'string';
497
- else if (typeStr === 'number')
498
- typeName = 'number';
499
- else if (typeStr === 'bool' || typeStr === 'boolean')
500
- typeName = 'bool';
501
- else if (typeStr === 'list' || typeStr === 'array')
502
- typeName = 'list';
503
- else if (typeStr === 'dict' || typeStr === 'object')
504
- typeName = 'dict';
505
- else if (typeStr === 'vector')
506
- typeName = 'vector';
507
- const param = {
508
- name: paramName,
509
- typeName,
510
- defaultValue: null,
511
- annotations: {},
512
- };
513
- // Add description only if it exists (optional property)
514
- if (typeof meta['description'] === 'string') {
515
- param.description =
516
- meta['description'];
517
- }
518
- return param;
519
- });
520
- // Create enhanced ApplicationCallable with params metadata
521
- const baseCallable = toolFnValue;
522
- enhancedCallable = {
523
- __type: 'callable',
524
- kind: 'application',
525
- params,
526
- fn: baseCallable.fn,
527
- description,
528
- isProperty: baseCallable.isProperty ?? false,
529
- };
530
- }
531
- toolsDict[name] = enhancedCallable;
532
- }
533
- // Extract options
534
- const system = typeof options['system'] === 'string'
535
- ? options['system']
536
- : factorySystem;
537
- const maxTokens = typeof options['max_tokens'] === 'number'
538
- ? options['max_tokens']
539
- : factoryMaxTokens;
540
- const maxErrors = typeof options['max_errors'] === 'number'
541
- ? options['max_errors']
542
- : 3;
543
- const maxTurns = typeof options['max_turns'] === 'number'
544
- ? options['max_turns']
545
- : 10;
546
- // Initialize conversation with prepended messages if provided
547
- const messages = [];
548
- if (system !== undefined) {
549
- messages.push({
550
- role: 'system',
551
- content: system,
552
- });
553
- }
554
- if ('messages' in options && Array.isArray(options['messages'])) {
555
- const prependedMessages = options['messages'];
556
- for (const msg of prependedMessages) {
557
- if (!msg || typeof msg !== 'object' || !('role' in msg)) {
558
- throw new RuntimeError('RILL-R004', "message missing required 'role' field");
559
- }
560
- const role = msg['role'];
561
- if (role !== 'user' && role !== 'assistant') {
562
- throw new RuntimeError('RILL-R004', `invalid role '${role}'`);
563
- }
564
- if (!('content' in msg) || typeof msg['content'] !== 'string') {
565
- throw new RuntimeError('RILL-R004', `${role} message requires 'content'`);
566
- }
567
- messages.push({
568
- role: role,
569
- content: msg['content'],
570
- });
571
- }
572
- }
573
- // Add the prompt as initial user message
574
- messages.push({
575
- role: 'user',
576
- content: prompt,
577
- });
578
- // Define OpenAI-specific callbacks for shared tool loop
579
- const callbacks = {
580
- // Build OpenAI Tool format from tool definitions
581
- buildTools: (toolDefs) => {
582
- return toolDefs.map((def) => ({
583
- type: 'function',
584
- function: {
585
- name: def.name,
586
- description: def.description,
587
- parameters: def.input_schema,
588
- },
589
- }));
590
- },
591
- // Call OpenAI API
592
- callAPI: async (msgs, tools) => {
593
- const apiParams = {
594
- model: factoryModel,
595
- max_tokens: maxTokens,
596
- messages: msgs,
597
- tools: tools,
598
- tool_choice: 'auto',
599
- };
600
- if (factoryTemperature !== undefined) {
601
- apiParams.temperature = factoryTemperature;
602
- }
603
- const response = await client.chat.completions.create(apiParams);
604
- // Normalize response to include usage in expected format
605
- return {
606
- ...response,
607
- usage: {
608
- input_tokens: response.usage?.prompt_tokens ?? 0,
609
- output_tokens: response.usage?.completion_tokens ?? 0,
610
- },
611
- };
612
- },
613
- // Extract tool calls from OpenAI response
614
- extractToolCalls: (response) => {
615
- if (!response ||
616
- typeof response !== 'object' ||
617
- !('choices' in response)) {
618
- return null;
619
- }
620
- const choices = response.choices;
621
- if (!Array.isArray(choices) || choices.length === 0) {
622
- return null;
623
- }
624
- const choice = choices[0];
625
- if (!choice ||
626
- typeof choice !== 'object' ||
627
- !('message' in choice)) {
628
- return null;
629
- }
630
- const message = choice.message;
631
- if (!message ||
632
- typeof message !== 'object' ||
633
- !('tool_calls' in message)) {
634
- return null;
635
- }
636
- const toolCalls = message
637
- .tool_calls;
638
- if (!toolCalls || !Array.isArray(toolCalls)) {
639
- return null;
640
- }
641
- // Filter for function tool calls and extract relevant data
642
- const functionToolCalls = toolCalls.filter((tc) => typeof tc === 'object' &&
643
- tc !== null &&
644
- 'type' in tc &&
645
- tc.type === 'function');
646
- return functionToolCalls.map((tc) => {
647
- // Type assertion safe because we filtered for function type
648
- const functionCall = tc;
649
- const args = functionCall.function.arguments;
650
- let parsedArgs;
651
- try {
652
- parsedArgs = JSON.parse(args);
653
- }
654
- catch {
655
- parsedArgs = {};
656
- }
657
- return {
658
- id: tc.id,
659
- name: functionCall.function.name,
660
- input: parsedArgs,
661
- };
662
- });
663
- },
664
- // Format tool results into OpenAI message format
665
- formatToolResult: (toolResults) => {
666
- // For OpenAI, we need to add assistant message with tool calls,
667
- // then tool messages with results
668
- // Since executeToolLoop already extracted the tool calls, we only
669
- // return the tool result messages here
670
- return toolResults.map((tr) => ({
671
- role: 'tool',
672
- tool_call_id: tr.id,
673
- content: tr.error
674
- ? JSON.stringify({ error: tr.error, code: 'RILL-R001' })
675
- : typeof tr.result === 'string'
676
- ? tr.result
677
- : JSON.stringify(tr.result),
678
- }));
679
- },
680
- };
681
- // Execute shared tool loop
682
- const loopResult = await executeToolLoop(messages, toolsDict, maxErrors, callbacks, (event, data) => {
683
- // Map shared events to OpenAI-specific events
684
- const eventMap = {
685
- tool_call: 'openai:tool_call',
686
- tool_result: 'openai:tool_result',
687
- };
688
- emitExtensionEvent(ctx, {
689
- event: eventMap[event] || event,
690
- subsystem: 'extension:openai',
691
- ...data,
692
- });
693
- }, maxTurns, ctx);
694
- // Extract response data
695
- const response = loopResult.response;
696
- const content = response?.choices[0]?.message?.content ?? '';
697
- const stopReason = loopResult.turns >= maxTurns
698
- ? 'max_turns'
699
- : (response?.choices[0]?.finish_reason ?? 'stop');
700
- // Build conversation history for response
701
- // Reconstruct full message history from messages array
702
- const fullMessages = [];
703
- for (const msg of messages) {
704
- if ('role' in msg && msg.role !== 'system') {
705
- const historyMsg = {
706
- role: msg.role,
707
- };
708
- if ('content' in msg && msg.content) {
709
- historyMsg['content'] = msg.content;
710
- }
711
- if ('tool_calls' in msg && msg.tool_calls) {
712
- historyMsg['tool_calls'] = msg.tool_calls;
713
- }
714
- fullMessages.push(historyMsg);
715
- }
716
- }
717
- // Add final assistant response if present
718
- if (response) {
719
- fullMessages.push({
720
- role: 'assistant',
721
- content,
722
- });
723
- }
724
- // Build result dict
725
- const result = {
726
- content,
727
- model: factoryModel,
728
- usage: {
729
- input: loopResult.totalTokens.input,
730
- output: loopResult.totalTokens.output,
731
- },
732
- stop_reason: stopReason,
733
- turns: loopResult.turns,
734
- messages: fullMessages,
735
- };
736
- // Emit success event (§4.10)
737
- const duration = Date.now() - startTime;
738
- emitExtensionEvent(ctx, {
739
- event: 'openai:tool_loop',
740
- subsystem: 'extension:openai',
741
- turns: loopResult.turns,
742
- total_duration: duration,
743
- usage: result.usage,
744
- });
745
- return result;
746
- }
747
- catch (error) {
748
- // Map error and emit failure event
749
- const duration = Date.now() - startTime;
750
- const rillError = mapProviderError('OpenAI', error, detectOpenAIError);
751
- emitExtensionEvent(ctx, {
752
- event: 'openai:error',
753
- subsystem: 'extension:openai',
754
- error: rillError.message,
755
- duration,
756
- });
757
- throw rillError;
758
- }
759
- },
760
- description: 'Execute tool-use loop with OpenAI API',
761
- returnType: 'dict',
762
- },
763
- };
764
- // IR-11: Attach dispose lifecycle method
765
- result.dispose = dispose;
766
- return result;
767
- }
768
- //# sourceMappingURL=factory.js.map