@x12i/ai-providers-router 4.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.
Files changed (58) hide show
  1. package/.metadata/anthropic.response-map.json +1 -0
  2. package/.metadata/google.response-map.json +1 -0
  3. package/.metadata/groq.response-map.json +1 -0
  4. package/.metadata/llm-request-config-registry.json +111 -0
  5. package/.metadata/llm-response-config-registry.json +1 -0
  6. package/.metadata/model-aliases.json +1 -0
  7. package/.metadata/model-normalization.json +1 -0
  8. package/.metadata/moonshot.response-map.json +1 -0
  9. package/.metadata/openai.response-map.json +1 -0
  10. package/.metadata/openrouter_catalog_with_vendor_mapping.json +15781 -0
  11. package/.metadata/reasoning-support.json +159 -0
  12. package/.metadata/xai.response-map.json +1 -0
  13. package/README.md +480 -0
  14. package/dist/adapters/grok/GrokAdapter.d.ts +50 -0
  15. package/dist/adapters/grok/GrokAdapter.js +342 -0
  16. package/dist/adapters/openai/OpenAIAdapter.d.ts +50 -0
  17. package/dist/adapters/openai/OpenAIAdapter.js +401 -0
  18. package/dist/adapters/openrouter/OpenRouterAdapter.d.ts +87 -0
  19. package/dist/adapters/openrouter/OpenRouterAdapter.js +1449 -0
  20. package/dist/adapters/openrouter/reasoning-capabilities.d.ts +26 -0
  21. package/dist/adapters/openrouter/reasoning-capabilities.js +79 -0
  22. package/dist/discovery.d.ts +6 -0
  23. package/dist/discovery.js +114 -0
  24. package/dist/errors.d.ts +27 -0
  25. package/dist/errors.js +33 -0
  26. package/dist/factory.d.ts +15 -0
  27. package/dist/factory.js +206 -0
  28. package/dist/gateway.d.ts +22 -0
  29. package/dist/gateway.js +154 -0
  30. package/dist/index.d.ts +9 -0
  31. package/dist/index.js +42 -0
  32. package/dist/interceptors.d.ts +10 -0
  33. package/dist/interceptors.js +1 -0
  34. package/dist/logger.d.ts +70 -0
  35. package/dist/logger.js +222 -0
  36. package/dist/openrouter-catalog.d.ts +119 -0
  37. package/dist/openrouter-catalog.js +222 -0
  38. package/dist/providers/OpenRouterProvider.d.ts +16 -0
  39. package/dist/providers/OpenRouterProvider.js +171 -0
  40. package/dist/registry/AdapterRegistry.d.ts +86 -0
  41. package/dist/registry/AdapterRegistry.js +36 -0
  42. package/dist/registry/ProviderRegistry.d.ts +24 -0
  43. package/dist/registry/ProviderRegistry.js +46 -0
  44. package/dist/router/Router.d.ts +33 -0
  45. package/dist/router/Router.js +258 -0
  46. package/dist/router/RouterTypes.d.ts +138 -0
  47. package/dist/router/RouterTypes.js +5 -0
  48. package/dist/router/RouterWrapper.d.ts +83 -0
  49. package/dist/router/RouterWrapper.js +744 -0
  50. package/dist/router.d.ts +13 -0
  51. package/dist/router.js +8 -0
  52. package/dist/types.d.ts +33 -0
  53. package/dist/types.js +1 -0
  54. package/dist/utils/esm-compat.d.ts +9 -0
  55. package/dist/utils/esm-compat.js +13 -0
  56. package/dist/utils/ids.d.ts +4 -0
  57. package/dist/utils/ids.js +6 -0
  58. package/package.json +66 -0
@@ -0,0 +1,1449 @@
1
+ import { openRouterCatalog } from '../../openrouter-catalog.js';
2
+ import { getReasoningCapabilitiesFromRegistry, hasReasoningParamInCatalog } from './reasoning-capabilities.js';
3
+ /**
4
+ * Router-side adapter for OpenRouter provider
5
+ * Converts router requests to ProviderSDKCallSpec and parses responses
6
+ * Does NOT use ai-io-normalizer - parses OpenAI-compatible responses directly
7
+ */
8
+ export class OpenRouterAdapter {
9
+ constructor() {
10
+ this.provider = 'openrouter';
11
+ }
12
+ /**
13
+ * Normalize model name for OpenRouter using catalog data
14
+ * Uses the catalog to properly map provider + model to OpenRouter format
15
+ */
16
+ async normalizeModelName(model, providerName) {
17
+ try {
18
+ // Try to normalize using catalog data
19
+ return await openRouterCatalog.normalizeModelName(model, providerName);
20
+ }
21
+ catch (error) {
22
+ // Fallback to old logic if catalog fails to load
23
+ console.warn('Failed to use OpenRouter catalog for model normalization, falling back to legacy logic:', error);
24
+ // Legacy fallback logic
25
+ if (model.includes('/')) {
26
+ return model;
27
+ }
28
+ if (providerName) {
29
+ const prefix = this.getProviderPrefix(providerName);
30
+ return `${prefix}/${model}`;
31
+ }
32
+ return `openai/${model}`;
33
+ }
34
+ }
35
+ /**
36
+ * Map provider name to OpenRouter model prefix (legacy fallback)
37
+ */
38
+ getProviderPrefix(providerName) {
39
+ const mapping = {
40
+ 'openai': 'openai',
41
+ 'grok': 'xai',
42
+ 'xai': 'xai',
43
+ 'anthropic': 'anthropic',
44
+ 'google': 'google',
45
+ 'groq': 'groq',
46
+ 'meta': 'meta',
47
+ 'mistral': 'mistral',
48
+ 'cohere': 'cohere',
49
+ 'perplexity': 'perplexity',
50
+ };
51
+ return mapping[providerName.toLowerCase()] || providerName.toLowerCase();
52
+ }
53
+ /**
54
+ * Get reasoning capabilities for a model
55
+ * Uses JSON registry for cross-vendor support detection
56
+ */
57
+ async getReasoningCapabilities(modelId) {
58
+ try {
59
+ // Check if model exists in catalog
60
+ const model = await openRouterCatalog.findModelById(modelId);
61
+ if (!model) {
62
+ return {
63
+ supportsEffort: false,
64
+ supportsMaxTokens: false,
65
+ supportsSummary: false,
66
+ supportsTrace: false,
67
+ supportsEncrypted: false,
68
+ supportsReasoningDetails: false,
69
+ mode: 'none',
70
+ };
71
+ }
72
+ // First, try registry-based detection (router-owned source of truth)
73
+ const registryCapabilities = getReasoningCapabilitiesFromRegistry(model.canonicalSlug);
74
+ if (registryCapabilities) {
75
+ return {
76
+ supportsEffort: registryCapabilities.mode === 'effort' || registryCapabilities.mode === 'both',
77
+ supportsMaxTokens: registryCapabilities.mode === 'max_tokens' || registryCapabilities.mode === 'both',
78
+ supportsSummary: registryCapabilities.visibility.summary,
79
+ supportsTrace: registryCapabilities.visibility.text || registryCapabilities.visibility.encrypted,
80
+ supportsEncrypted: registryCapabilities.visibility.encrypted,
81
+ supportsReasoningDetails: registryCapabilities.supportsReasoningDetails,
82
+ supportedEfforts: registryCapabilities.supportedEfforts,
83
+ mode: registryCapabilities.mode,
84
+ vendorId: registryCapabilities.vendorId,
85
+ };
86
+ }
87
+ // Fallback: Check catalog metadata for reasoning parameters
88
+ const hasReasoning = hasReasoningParamInCatalog(model);
89
+ if (hasReasoning) {
90
+ // Conservative defaults: assume effort mode if reasoning param exists.
91
+ // IMPORTANT: visibility (summary/trace/encrypted) is UNKNOWN without registry;
92
+ // do NOT assume trace/encrypted support from catalog alone.
93
+ return {
94
+ supportsEffort: true,
95
+ supportsMaxTokens: false,
96
+ supportsSummary: false, // Unknown without registry => false
97
+ supportsTrace: false, // Unknown without registry => false
98
+ supportsEncrypted: false, // Unknown without registry => false
99
+ supportsReasoningDetails: true, // Has reasoning param, so supports reasoning_details
100
+ supportedEfforts: ['low', 'medium', 'high'],
101
+ mode: 'effort',
102
+ };
103
+ }
104
+ // No reasoning support
105
+ return {
106
+ supportsEffort: false,
107
+ supportsMaxTokens: false,
108
+ supportsSummary: false,
109
+ supportsTrace: false,
110
+ supportsEncrypted: false,
111
+ supportsReasoningDetails: false,
112
+ mode: 'none',
113
+ };
114
+ }
115
+ catch (error) {
116
+ // On catalog failure, be conservative
117
+ return {
118
+ supportsEffort: false,
119
+ supportsMaxTokens: false,
120
+ supportsSummary: false,
121
+ supportsTrace: false,
122
+ supportsEncrypted: false,
123
+ supportsReasoningDetails: false,
124
+ mode: 'none',
125
+ };
126
+ }
127
+ }
128
+ /**
129
+ * Map unified reasoning request to provider-specific format
130
+ * Implements deterministic downgrade rules based on capabilities
131
+ * Supports both effort (OpenAI/xAI) and max_tokens (Anthropic/Gemini) modes
132
+ */
133
+ mapReasoningToProvider(reasoning, capabilities) {
134
+ const applied = {
135
+ effort: reasoning?.effort || 'none',
136
+ visibility: reasoning?.visibility || 'none',
137
+ };
138
+ const warnings = [];
139
+ const errors = [];
140
+ let providerReasoning = undefined;
141
+ const onUnsupported = reasoning?.onUnsupported || 'downgrade';
142
+ const mode = capabilities.mode || 'none';
143
+ // Normalize xhigh -> high before validation (deterministic normalization rule)
144
+ if (applied.effort === 'xhigh') {
145
+ applied.effort = 'high';
146
+ warnings.push({
147
+ code: 'EFFORT_NORMALIZED',
148
+ message: `Reasoning effort 'xhigh' normalized to 'high'`,
149
+ });
150
+ }
151
+ // Handle effort/max_tokens mapping based on mode
152
+ if (mode === 'effort' || mode === 'both') {
153
+ // Handle effort mapping (OpenAI/xAI style)
154
+ if (applied.effort !== 'none' && capabilities.supportsEffort) {
155
+ // Validate effort is supported
156
+ if (capabilities.supportedEfforts && !capabilities.supportedEfforts.includes(applied.effort)) {
157
+ const error = {
158
+ code: 'EFFORT_UNSUPPORTED',
159
+ message: `Reasoning effort '${applied.effort}' not supported by model`,
160
+ };
161
+ if (onUnsupported === 'error') {
162
+ errors.push(error);
163
+ }
164
+ else {
165
+ warnings.push({
166
+ code: 'EFFORT_IGNORED',
167
+ message: `${error.message}, using default`,
168
+ });
169
+ applied.effort = 'none';
170
+ }
171
+ }
172
+ else {
173
+ providerReasoning = providerReasoning || {};
174
+ providerReasoning.effort = applied.effort;
175
+ }
176
+ }
177
+ else if (applied.effort !== 'none' && !capabilities.supportsEffort) {
178
+ const error = {
179
+ code: 'REASONING_UNSUPPORTED',
180
+ message: `Model does not support reasoning effort control`,
181
+ };
182
+ if (onUnsupported === 'error') {
183
+ errors.push(error);
184
+ }
185
+ else {
186
+ warnings.push(error);
187
+ applied.effort = 'none';
188
+ }
189
+ }
190
+ }
191
+ // Handle max_tokens mapping (Anthropic/Gemini style)
192
+ if ((mode === 'max_tokens' || mode === 'both') && reasoning?.maxTokens !== undefined) {
193
+ if (capabilities.supportsMaxTokens) {
194
+ providerReasoning = providerReasoning || {};
195
+ providerReasoning.max_tokens = reasoning?.maxTokens;
196
+ }
197
+ else if (reasoning?.maxTokens !== undefined) {
198
+ const error = {
199
+ code: 'MAX_TOKENS_UNSUPPORTED',
200
+ message: `Model does not support reasoning.max_tokens`,
201
+ };
202
+ if (onUnsupported === 'error') {
203
+ errors.push(error);
204
+ }
205
+ else {
206
+ warnings.push({
207
+ code: 'MAX_TOKENS_IGNORED',
208
+ message: `${error.message}, ignoring maxTokens`,
209
+ });
210
+ }
211
+ }
212
+ }
213
+ // Convert effort to max_tokens if needed (for max_tokens-only models)
214
+ if (mode === 'max_tokens' && applied.effort !== 'none' && reasoning?.maxTokens === undefined) {
215
+ // Convert effort to approximate max_tokens
216
+ // Using OpenRouter's documented ratios: low=25%, medium=50%, high=100%
217
+ const effortToMaxTokens = {
218
+ 'low': 1000, // Conservative default
219
+ 'medium': 2000,
220
+ 'high': 4000,
221
+ };
222
+ const estimatedMaxTokens = effortToMaxTokens[applied.effort] || 2000;
223
+ providerReasoning = providerReasoning || {};
224
+ providerReasoning.max_tokens = estimatedMaxTokens;
225
+ warnings.push({
226
+ code: 'EFFORT_CONVERTED_TO_MAX_TOKENS',
227
+ message: `Reasoning effort '${applied.effort}' converted to max_tokens=${estimatedMaxTokens} (model uses max_tokens mode)`,
228
+ });
229
+ }
230
+ // Handle visibility - support both text and encrypted traces
231
+ if (applied.visibility !== 'none') {
232
+ const supportsRequestedVisibility = (applied.visibility === 'summary' && capabilities.supportsSummary) ||
233
+ (applied.visibility === 'trace' && capabilities.supportsTrace);
234
+ if (supportsRequestedVisibility) {
235
+ // Visibility is supported, keep as requested
236
+ }
237
+ else {
238
+ const error = {
239
+ code: 'VISIBILITY_UNSUPPORTED',
240
+ message: `Reasoning visibility '${applied.visibility}' not supported by model`,
241
+ };
242
+ if (onUnsupported === 'error') {
243
+ errors.push(error);
244
+ }
245
+ else {
246
+ warnings.push({
247
+ code: 'VISIBILITY_DOWNGRADED',
248
+ message: `${error.message}, downgraded to 'none'`,
249
+ });
250
+ applied.visibility = 'none';
251
+ }
252
+ }
253
+ }
254
+ return { providerReasoning, applied, warnings, errors };
255
+ }
256
+ async buildCallSpec(input) {
257
+ const { requestId, mode, request, exec } = input;
258
+ // Extract model from request (could be in request.model or request.config.model)
259
+ let model = request.model || request.config?.model || 'openai/gpt-4o-mini';
260
+ // Determine the original provider name from the request context
261
+ // When OpenRouter mode is enabled, the interceptor sets request.config.provider
262
+ // to preserve the original provider name (e.g., "openai", "grok") for model mapping
263
+ const originalProvider = request.config?.provider;
264
+ // Normalize model name for OpenRouter using catalog data
265
+ // (e.g., "gpt-4o" + provider="openai" → "openai/gpt-4o")
266
+ model = await this.normalizeModelName(model, originalProvider);
267
+ // Validate model is available in OpenRouter catalog
268
+ try {
269
+ const isAvailable = await openRouterCatalog.isModelAvailable(model);
270
+ if (!isAvailable) {
271
+ console.warn(`Model '${model}' not found in OpenRouter catalog. This may result in a runtime error.`);
272
+ }
273
+ }
274
+ catch (error) {
275
+ // Don't fail the request if catalog validation fails, just log warning
276
+ console.warn('Could not validate model against OpenRouter catalog:', error);
277
+ }
278
+ // Determine operation based on reasoning visibility request
279
+ // If visibility is requested (trace/summary), prefer Responses API if model supports it
280
+ // Otherwise use Chat Completions (OpenRouter's default)
281
+ // Note: reasoning_details can appear in both formats, but Responses API is preferred for reasoning
282
+ // IMPORTANT: Responses API requires reasoning.effort, so only use it for effort-mode models
283
+ const requestedVisibility = request.config?.reasoning?.visibility;
284
+ const useResponsesAPIForReasoning = requestedVisibility && requestedVisibility !== 'none';
285
+ // Check if model supports reasoning and if we should use Responses API
286
+ let useResponsesAPI = false;
287
+ if (useResponsesAPIForReasoning && request.config?.reasoning) {
288
+ const capabilities = await this.getReasoningCapabilities(model);
289
+ // Use Responses API only if:
290
+ // 1. Model supports reasoning details
291
+ // 2. Model uses effort mode (not max_tokens mode) - Responses API requires reasoning.effort
292
+ // Note: reasoning_details can appear in both Chat Completions and Responses API formats
293
+ // Chat Completions supports reasoning_details[] for all vendors, so it's safer for max_tokens models
294
+ const usesEffortMode = capabilities.mode === 'effort' || capabilities.mode === 'both';
295
+ useResponsesAPI = capabilities.supportsReasoningDetails === true && usesEffortMode;
296
+ }
297
+ // Determine operation
298
+ const operation = useResponsesAPI
299
+ ? (mode === 'stream' ? 'openai.responses.stream' : 'openai.responses.create')
300
+ : (mode === 'stream' ? 'openai.chat.completions.create' : 'openai.chat.completions.create');
301
+ // Build args based on API type
302
+ const args = {
303
+ model,
304
+ };
305
+ if (useResponsesAPI) {
306
+ // Responses API format: convert messages to input format
307
+ let inputValue;
308
+ if (request.messages && Array.isArray(request.messages)) {
309
+ // Convert messages array to input text format
310
+ const systemMessages = request.messages.filter((m) => m.role === 'system');
311
+ const userMessages = request.messages.filter((m) => m.role === 'user');
312
+ const assistantMessages = request.messages.filter((m) => m.role === 'assistant');
313
+ const parts = [];
314
+ if (systemMessages.length > 0) {
315
+ parts.push(...systemMessages.map((m) => `System: ${m.content || ''}`));
316
+ }
317
+ if (userMessages.length > 0) {
318
+ parts.push(...userMessages.map((m) => `User: ${m.content || ''}`));
319
+ }
320
+ if (assistantMessages.length > 0) {
321
+ parts.push(...assistantMessages.map((m) => `Assistant: ${m.content || ''}`));
322
+ }
323
+ inputValue = parts.join('\n\n');
324
+ }
325
+ else if (typeof request.inputData === 'string') {
326
+ inputValue = request.inputData;
327
+ }
328
+ else if (request.inputData) {
329
+ inputValue = { input_text: String(request.inputData) };
330
+ }
331
+ else if (request.instructions) {
332
+ inputValue = request.instructions;
333
+ }
334
+ else {
335
+ inputValue = JSON.stringify(request);
336
+ }
337
+ args.input = inputValue;
338
+ }
339
+ else {
340
+ // Chat Completions API format: use messages array
341
+ let messages = [];
342
+ if (request.messages && Array.isArray(request.messages)) {
343
+ messages = request.messages;
344
+ }
345
+ else if (typeof request.inputData === 'string') {
346
+ messages = [{ role: 'user', content: request.inputData }];
347
+ }
348
+ else if (request.inputData) {
349
+ messages = [{ role: 'user', content: String(request.inputData) }];
350
+ }
351
+ else if (request.instructions) {
352
+ messages = [{ role: 'user', content: request.instructions }];
353
+ }
354
+ else {
355
+ messages = [{ role: 'user', content: JSON.stringify(request) }];
356
+ }
357
+ args.messages = messages;
358
+ }
359
+ // Add config parameters
360
+ if (request.config) {
361
+ if (useResponsesAPI) {
362
+ // Responses API uses max_output_tokens
363
+ if (request.config.maxTokens !== undefined) {
364
+ args.max_output_tokens = request.config.maxTokens;
365
+ }
366
+ }
367
+ else {
368
+ // Chat Completions uses max_tokens
369
+ if (request.config.maxTokens !== undefined) {
370
+ args.max_tokens = request.config.maxTokens;
371
+ }
372
+ }
373
+ if (request.config.temperature !== undefined) {
374
+ args.temperature = request.config.temperature;
375
+ }
376
+ if (request.config.topP !== undefined) {
377
+ args.top_p = request.config.topP;
378
+ }
379
+ if (request.config.stop !== undefined) {
380
+ args.stop = request.config.stop;
381
+ }
382
+ if (request.config.stream !== undefined) {
383
+ args.stream = request.config.stream;
384
+ }
385
+ // Handle unified reasoning API
386
+ if (request.config.reasoning) {
387
+ // Normalize reasoning defaults at adapter boundary
388
+ const normalizedReasoning = {
389
+ effort: request.config.reasoning.effort || 'none',
390
+ visibility: request.config.reasoning.visibility || 'none',
391
+ onUnsupported: request.config.reasoning.onUnsupported || 'downgrade',
392
+ maxTokens: request.config.reasoning.maxTokens,
393
+ };
394
+ // Get capabilities (may have already been fetched above, but fetch again to be safe)
395
+ const capabilities = await this.getReasoningCapabilities(model);
396
+ const { providerReasoning, errors, warnings } = this.mapReasoningToProvider(normalizedReasoning, capabilities);
397
+ // Enforce onUnsupported semantics: never throw in downgrade mode
398
+ // Only throw if onUnsupported is 'error' AND there are actual errors
399
+ if (errors.length > 0 && normalizedReasoning.onUnsupported === 'error') {
400
+ const error = errors[0]; // Use the first error
401
+ throw new Error(`Reasoning request failed: ${error.message}`);
402
+ }
403
+ // In downgrade mode, convert errors to warnings (already done in mapReasoningToProvider, but ensure consistency)
404
+ // The mapper already handles this, but we ensure no errors slip through in downgrade mode
405
+ if (providerReasoning && Object.keys(providerReasoning).length > 0) {
406
+ args.reasoning = providerReasoning;
407
+ }
408
+ // Store reasoning request info for response parsing (with normalized requested)
409
+ args._router_reasoning = {
410
+ requested: normalizedReasoning,
411
+ capabilities,
412
+ };
413
+ }
414
+ }
415
+ // Set stream mode for streaming requests
416
+ if (mode === 'stream') {
417
+ args.stream = true;
418
+ }
419
+ return {
420
+ requestId,
421
+ provider: 'openrouter',
422
+ mode,
423
+ operation,
424
+ args,
425
+ exec: exec ? {
426
+ timeoutMs: exec.timeoutMs,
427
+ retries: exec.retries,
428
+ idempotencyKey: exec.idempotencyKey,
429
+ signal: exec.signal,
430
+ } : undefined,
431
+ };
432
+ }
433
+ parseResponse(input) {
434
+ const { requestId, execResult, request } = input;
435
+ const rawResponse = execResult.rawResponse;
436
+ // #region agent log
437
+ console.log('[DEBUG] parseResponse entry:', {
438
+ hasRawResponse: !!rawResponse,
439
+ rawResponseKeys: rawResponse ? Object.keys(rawResponse) : [],
440
+ rawResponseFormat: rawResponse?.format,
441
+ rawResponseOutputType: typeof rawResponse?.output,
442
+ rawResponseOutputIsArray: Array.isArray(rawResponse?.output),
443
+ rawResponseOutputLength: Array.isArray(rawResponse?.output) ? rawResponse.output.length : 'not-array',
444
+ rawMetaKeys: execResult.rawMeta ? Object.keys(execResult.rawMeta) : [],
445
+ rawMetaOriginalResponse: !!execResult.rawMeta?.originalResponse,
446
+ rawMetaRawResponse: !!execResult.rawMeta?.rawResponse,
447
+ });
448
+ // #endregion
449
+ // Note: The provider SDK may normalize Responses API v1 format to Chat Completions format.
450
+ // However, OpenRouter may return both formats in the same response, or preserve the output array.
451
+ // We check both rawResponse and rawMeta for the original response structure.
452
+ const originalResponse = execResult.rawMeta?.originalResponse ||
453
+ execResult.rawMeta?.rawResponse ||
454
+ rawResponse;
455
+ console.log('[DEBUG] originalResponse:', JSON.stringify(originalResponse, null, 2));
456
+ // #region agent log
457
+ const rawMetaOriginalResponseDebug = execResult.rawMeta?.originalResponse;
458
+ console.log('[DEBUG] originalResponse determined:', {
459
+ hasOriginalResponse: !!originalResponse,
460
+ originalResponseKeys: originalResponse ? Object.keys(originalResponse) : [],
461
+ originalResponseFormat: originalResponse?.format,
462
+ originalResponseOutputType: typeof originalResponse?.output,
463
+ originalResponseOutputIsArray: Array.isArray(originalResponse?.output),
464
+ originalResponseOutputLength: Array.isArray(originalResponse?.output) ? originalResponse.output.length : 'not-array',
465
+ hasRawMetaOriginalResponse: !!rawMetaOriginalResponseDebug,
466
+ rawMetaOriginalResponseKeys: rawMetaOriginalResponseDebug ? Object.keys(rawMetaOriginalResponseDebug) : [],
467
+ rawMetaOriginalResponseFormat: rawMetaOriginalResponseDebug?.format,
468
+ rawMetaOriginalResponseOutputLength: Array.isArray(rawMetaOriginalResponseDebug?.output) ? rawMetaOriginalResponseDebug.output.length : 'not-array',
469
+ rawMetaOriginalResponseOutputSample: Array.isArray(rawMetaOriginalResponseDebug?.output) && rawMetaOriginalResponseDebug.output.length > 0
470
+ ? rawMetaOriginalResponseDebug.output.slice(0, 2).map((i) => ({ type: i?.type, keys: i ? Object.keys(i) : [] }))
471
+ : [],
472
+ isFromRawMetaOriginalResponse: !!execResult.rawMeta?.originalResponse,
473
+ isFromRawMetaRawResponse: !!execResult.rawMeta?.rawResponse,
474
+ isSameAsRawResponse: originalResponse === rawResponse,
475
+ originalResponseOutputSample: Array.isArray(originalResponse?.output) && originalResponse.output.length > 0
476
+ ? originalResponse.output.slice(0, 2).map((i) => ({ type: i?.type, keys: i ? Object.keys(i) : [] }))
477
+ : [],
478
+ });
479
+ // #endregion
480
+ // Parse OpenAI-compatible response directly (no ai-io-normalizer)
481
+ // OpenRouter may return either Chat Completions format or newer Responses API format
482
+ let outputText;
483
+ let usage;
484
+ let reasoning;
485
+ // Extract reasoning request info if present
486
+ const reasoningRequest = request?._callSpec?.args?._router_reasoning;
487
+ // Handle OpenAI Responses API format (openai-responses-v1)
488
+ // OpenRouter may return output array even if format field is not set
489
+ // Check both rawResponse (normalized) and originalResponse (unnormalized)
490
+ // IMPORTANT: Check if output array contains Responses API format items (with 'type' field)
491
+ // vs Chat Completions format items (with 'content', 'role' fields)
492
+ const hasOutputArray = Array.isArray(rawResponse?.output);
493
+ // Check rawMeta.originalResponse directly (might have the unnormalized response with output array)
494
+ const rawMetaOriginalResponse = execResult.rawMeta?.originalResponse;
495
+ const hasOutputArrayInRawMeta = Array.isArray(rawMetaOriginalResponse?.output);
496
+ const hasOutputArrayInOriginal = Array.isArray(originalResponse?.output) && originalResponse !== rawResponse;
497
+ const outputHasResponsesFormat = hasOutputArray && rawResponse.output.length > 0 && rawResponse.output.some((item) => item.type);
498
+ const rawMetaHasResponsesFormat = hasOutputArrayInRawMeta && rawMetaOriginalResponse.output.length > 0 && rawMetaOriginalResponse.output.some((item) => item.type);
499
+ const originalHasResponsesFormat = hasOutputArrayInOriginal && originalResponse.output.length > 0 && originalResponse.output.some((item) => item.type);
500
+ const isResponsesAPIFormat = rawResponse?.format === 'openai-responses-v1' || outputHasResponsesFormat || rawMetaHasResponsesFormat || originalHasResponsesFormat;
501
+ // Determine the correct output array to use (for both parsing and final response)
502
+ // Priority: rawMeta.originalResponse > rawResponse.output > originalResponse.output
503
+ // Declare outside if block so it can be reused when creating responseWithStatus
504
+ let outputArray = [];
505
+ if (rawMetaHasResponsesFormat) {
506
+ outputArray = rawMetaOriginalResponse.output;
507
+ }
508
+ else if (outputHasResponsesFormat) {
509
+ outputArray = rawResponse.output;
510
+ }
511
+ else if (originalHasResponsesFormat) {
512
+ outputArray = originalResponse.output;
513
+ }
514
+ else if (isResponsesAPIFormat && hasOutputArray && rawResponse.output.length > 0) {
515
+ // Fallback: if format is openai-responses-v1 and output exists, use it even if format detection didn't match
516
+ // This handles cases where the output array structure is correct but format detection logic missed it
517
+ outputArray = rawResponse.output;
518
+ }
519
+ // #region agent log
520
+ console.log('[DEBUG] output array detection:', {
521
+ hasOutputArray,
522
+ rawResponseOutputLength: Array.isArray(rawResponse?.output) ? rawResponse.output.length : 'not-array',
523
+ hasOutputArrayInRawMeta,
524
+ rawMetaOutputLength: hasOutputArrayInRawMeta ? rawMetaOriginalResponse.output.length : 'not-array',
525
+ hasOutputArrayInOriginal,
526
+ originalResponseOutputLength: Array.isArray(originalResponse?.output) ? originalResponse.output.length : 'not-array',
527
+ outputHasResponsesFormat,
528
+ rawMetaHasResponsesFormat,
529
+ originalHasResponsesFormat,
530
+ isResponsesAPIFormat,
531
+ selectedOutputArrayLength: outputArray.length,
532
+ selectedOutputArraySource: rawMetaHasResponsesFormat ? 'rawMeta' : (outputHasResponsesFormat ? 'rawResponse' : (originalHasResponsesFormat ? 'originalResponse' : 'none')),
533
+ selectedOutputArraySample: outputArray.length > 0 ? outputArray.slice(0, 2).map((i) => ({ type: i?.type, keys: i ? Object.keys(i) : [] })) : [],
534
+ });
535
+ // #endregion
536
+ // #region agent log
537
+ // Check for choices in multiple locations (Fix 3: Check rawMeta for Choices)
538
+ const choicesInRawResponse = rawResponse?.choices;
539
+ const choicesInRawMeta = execResult.rawMeta?.originalResponse?.choices || execResult.rawMeta?.rawResponse?.choices;
540
+ const choicesInOriginal = originalResponse?.choices;
541
+ const hasChoicesInRawResponse = choicesInRawResponse && Array.isArray(choicesInRawResponse) && choicesInRawResponse.length > 0;
542
+ const hasChoicesInRawMeta = choicesInRawMeta && Array.isArray(choicesInRawMeta) && choicesInRawMeta.length > 0;
543
+ const hasChoicesInOriginal = choicesInOriginal && Array.isArray(choicesInOriginal) && choicesInOriginal.length > 0;
544
+ console.log('[DEBUG] Choices detection:', {
545
+ hasChoicesInRawResponse,
546
+ hasChoicesInRawMeta,
547
+ hasChoicesInOriginal,
548
+ choicesLengthRawResponse: hasChoicesInRawResponse ? choicesInRawResponse.length : 0,
549
+ choicesLengthRawMeta: hasChoicesInRawMeta ? choicesInRawMeta.length : 0,
550
+ choicesLengthOriginal: hasChoicesInOriginal ? choicesInOriginal.length : 0,
551
+ isResponsesAPIFormat,
552
+ outputArrayLength: outputArray.length,
553
+ });
554
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:625', message: 'Choices detection', data: { hasChoicesInRawResponse, hasChoicesInRawMeta, hasChoicesInOriginal, choicesLengthRawResponse: hasChoicesInRawResponse ? choicesInRawResponse.length : 0, choicesLengthRawMeta: hasChoicesInRawMeta ? choicesInRawMeta.length : 0, choicesLengthOriginal: hasChoicesInOriginal ? choicesInOriginal.length : 0, isResponsesAPIFormat, outputArrayLength: outputArray.length }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'A' }) }).catch(() => { });
555
+ // #endregion
556
+ console.log('[DEBUG] Format decision:', {
557
+ isResponsesAPIFormat,
558
+ outputArrayLength: outputArray.length,
559
+ willParseResponsesAPI: isResponsesAPIFormat && outputArray.length > 0,
560
+ });
561
+ // PRODUCTION-GRADE FIX: Use unified content extraction function first
562
+ // This ensures we extract content regardless of format detection issues
563
+ const extractedContent = this.extractContentFromResponse(rawResponse, originalResponse, execResult, outputArray);
564
+ if (extractedContent !== undefined) {
565
+ outputText = extractedContent;
566
+ console.log('[DEBUG] Content extracted via unified extraction function:', {
567
+ outputTextLength: outputText.length,
568
+ outputTextPreview: outputText.substring(0, 50),
569
+ });
570
+ }
571
+ if (isResponsesAPIFormat && outputArray.length > 0) {
572
+ // New Responses API format - parse the output array we selected
573
+ console.log('[DEBUG] Parsing Responses API format');
574
+ const encryptedArtifacts = [];
575
+ let summaryText;
576
+ let traceText;
577
+ // Log output array contents when reasoning was requested (for debugging)
578
+ const reasoningRequested = reasoningRequest?.requested?.effort !== 'none' || reasoningRequest?.requested?.visibility !== 'none';
579
+ if (reasoningRequested && outputArray.length > 0) {
580
+ console.log(`[OpenRouterAdapter] Parsing ${outputArray.length} output items for reasoning artifacts...`);
581
+ }
582
+ // #region agent log
583
+ console.log('[DEBUG] Starting output array parsing:', {
584
+ outputArrayLength: outputArray.length,
585
+ outputArrayItems: outputArray.map((i) => ({ type: i?.type, id: i?.id, hasSummary: !!i?.summary, hasText: !!i?.text })),
586
+ });
587
+ // #endregion
588
+ for (const item of outputArray) {
589
+ // Note: Content extraction is already done by unified function above
590
+ // This loop now focuses on reasoning artifacts only
591
+ if (item.type === 'text' && item.content && !outputText) {
592
+ // Fallback: Extract text content if unified function didn't find it
593
+ if (typeof item.content === 'string' && item.content.trim()) {
594
+ outputText = item.content;
595
+ }
596
+ }
597
+ else if (item.type === 'message' && item.content) {
598
+ // Responses API may return message type with content array or object
599
+ if (Array.isArray(item.content)) {
600
+ for (const contentItem of item.content) {
601
+ // Note: Content extraction is already done by unified function above
602
+ // This is fallback only if unified function didn't find content
603
+ if (!outputText) {
604
+ if (contentItem.type === 'text' && contentItem.text && typeof contentItem.text === 'string' && contentItem.text.trim()) {
605
+ outputText = contentItem.text;
606
+ }
607
+ else if (contentItem.type === 'output_text' && contentItem.text && typeof contentItem.text === 'string' && contentItem.text.trim()) {
608
+ outputText = contentItem.text;
609
+ }
610
+ else if (contentItem.type === 'text' && contentItem.content && typeof contentItem.content === 'string' && contentItem.content.trim()) {
611
+ outputText = contentItem.content;
612
+ }
613
+ else if (!contentItem.type && typeof contentItem === 'string' && contentItem.trim()) {
614
+ outputText = contentItem;
615
+ }
616
+ else if (!contentItem.type && contentItem.text && typeof contentItem.text === 'string' && contentItem.text.trim()) {
617
+ outputText = contentItem.text;
618
+ }
619
+ }
620
+ // ✅ NEW: allow summary inside message content
621
+ if (contentItem.type === 'reasoning.summary') {
622
+ summaryText = contentItem.summary ?? contentItem.text ?? summaryText;
623
+ }
624
+ // ✅ NEW: allow readable trace inside message content
625
+ if (contentItem.type === 'reasoning.text') {
626
+ traceText = contentItem.text ?? traceText;
627
+ }
628
+ // existing encrypted handling...
629
+ else if (contentItem.type === 'reasoning' && contentItem.reasoning) {
630
+ // Handle reasoning content within message
631
+ if (contentItem.reasoning.encrypted) {
632
+ encryptedArtifacts.push({
633
+ format: 'openai-responses-v1',
634
+ id: item.id || contentItem.reasoning.id,
635
+ index: item.index,
636
+ data: contentItem.reasoning.encrypted,
637
+ });
638
+ }
639
+ }
640
+ }
641
+ }
642
+ else if (typeof item.content === 'string' && item.content.trim() && !outputText) {
643
+ outputText = item.content;
644
+ }
645
+ else if (item.content && typeof item.content === 'object' && !outputText) {
646
+ // Content might be an object with text or other fields
647
+ if (item.content.text && typeof item.content.text === 'string' && item.content.text.trim()) {
648
+ outputText = item.content.text;
649
+ }
650
+ // Check for reasoning in content object
651
+ if (item.content.reasoning && item.content.reasoning.encrypted) {
652
+ encryptedArtifacts.push({
653
+ format: 'openai-responses-v1',
654
+ id: item.id,
655
+ index: item.index,
656
+ data: item.content.reasoning.encrypted,
657
+ });
658
+ }
659
+ }
660
+ }
661
+ else if (item.type === 'reasoning.summary') {
662
+ // ✅ NEW: handle reasoning.summary as top-level output item
663
+ summaryText = item.summary ?? item.text ?? summaryText;
664
+ // #region agent log
665
+ console.log('[DEBUG] Found reasoning.summary:', { summaryText, itemId: item.id, itemKeys: Object.keys(item) });
666
+ // #endregion
667
+ }
668
+ else if (item.type === 'reasoning.text') {
669
+ // ✅ NEW: handle reasoning.text as top-level output item
670
+ // Append trace text (may be multiple chunks)
671
+ traceText = traceText ? `${traceText}\n${item.text}` : item.text;
672
+ // #region agent log
673
+ console.log('[DEBUG] Found reasoning.text:', { traceText, itemId: item.id });
674
+ // #endregion
675
+ }
676
+ else if (item.type === 'reasoning.encrypted' || (item.reasoning && item.reasoning.encrypted)) {
677
+ // Collect encrypted reasoning artifacts (direct or nested)
678
+ const encryptedData = item.data || item.reasoning?.encrypted || item.reasoning;
679
+ encryptedArtifacts.push({
680
+ format: 'openai-responses-v1',
681
+ id: item.id,
682
+ index: item.index,
683
+ data: encryptedData,
684
+ });
685
+ if (reasoningRequested) {
686
+ console.log(`[OpenRouterAdapter] ✅ Found encrypted reasoning artifact: id=${item.id}, index=${item.index}`);
687
+ }
688
+ }
689
+ else if (reasoningRequested && item.type) {
690
+ // Log other item types when reasoning was requested (for debugging)
691
+ console.log(`[OpenRouterAdapter] Output item type: ${item.type} (not reasoning.encrypted)`);
692
+ }
693
+ }
694
+ if (reasoningRequested) {
695
+ console.log(`[OpenRouterAdapter] Extracted ${encryptedArtifacts.length} encrypted reasoning artifacts from ${outputArray.length} output items`);
696
+ }
697
+ // Extract usage if available
698
+ if (rawResponse?.usage) {
699
+ usage = {
700
+ inputTokens: rawResponse.usage.input_tokens,
701
+ outputTokens: rawResponse.usage.output_tokens,
702
+ totalTokens: rawResponse.usage.total_tokens,
703
+ };
704
+ }
705
+ // Build unified reasoning response (ONLY source of truth)
706
+ reasoning = this.buildReasoningResponse(reasoningRequest, {
707
+ encrypted: encryptedArtifacts,
708
+ summaryText,
709
+ traceText,
710
+ });
711
+ }
712
+ // Handle traditional Chat Completions format
713
+ // Fix 3: Check rawMeta for choices as fallback
714
+ else {
715
+ console.log('[DEBUG] Entering Chat Completions parsing branch');
716
+ // Find choices in any location (rawResponse, rawMeta, originalResponse)
717
+ let choices = undefined;
718
+ if (rawResponse?.choices && Array.isArray(rawResponse.choices) && rawResponse.choices.length > 0) {
719
+ choices = rawResponse.choices;
720
+ }
721
+ else if (execResult.rawMeta?.originalResponse?.choices && Array.isArray(execResult.rawMeta.originalResponse.choices) && execResult.rawMeta.originalResponse.choices.length > 0) {
722
+ choices = execResult.rawMeta.originalResponse.choices;
723
+ }
724
+ else if (execResult.rawMeta?.rawResponse?.choices && Array.isArray(execResult.rawMeta.rawResponse.choices) && execResult.rawMeta.rawResponse.choices.length > 0) {
725
+ choices = execResult.rawMeta.rawResponse.choices;
726
+ }
727
+ else if (originalResponse?.choices && Array.isArray(originalResponse.choices) && originalResponse.choices.length > 0) {
728
+ choices = originalResponse.choices;
729
+ }
730
+ console.log('[DEBUG] Choices search result:', {
731
+ foundChoices: !!choices,
732
+ choicesLength: choices?.length || 0,
733
+ choicesSource: choices === rawResponse?.choices ? 'rawResponse' :
734
+ choices === execResult.rawMeta?.originalResponse?.choices ? 'rawMeta.originalResponse' :
735
+ choices === execResult.rawMeta?.rawResponse?.choices ? 'rawMeta.rawResponse' :
736
+ choices === originalResponse?.choices ? 'originalResponse' : 'none',
737
+ });
738
+ if (choices && choices.length > 0) {
739
+ const firstChoice = choices[0];
740
+ // #region agent log
741
+ console.log('[DEBUG] Chat Completions parsing entry:', {
742
+ choicesLength: choices.length,
743
+ hasFirstChoice: !!firstChoice,
744
+ hasMessage: !!firstChoice?.message,
745
+ messageKeys: firstChoice?.message ? Object.keys(firstChoice.message) : [],
746
+ hasContent: firstChoice?.message?.content !== undefined,
747
+ contentType: typeof firstChoice?.message?.content,
748
+ contentValue: firstChoice?.message?.content,
749
+ contentIsNull: firstChoice?.message?.content === null,
750
+ contentIsUndefined: firstChoice?.message?.content === undefined,
751
+ });
752
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:758', message: 'Chat Completions parsing entry', data: { choicesLength: choices.length, hasFirstChoice: !!firstChoice, hasMessage: !!firstChoice?.message, messageKeys: firstChoice?.message ? Object.keys(firstChoice.message) : [], hasContent: firstChoice?.message?.content !== undefined, contentType: typeof firstChoice?.message?.content, contentValue: firstChoice?.message?.content, contentIsNull: firstChoice?.message?.content === null, contentIsUndefined: firstChoice?.message?.content === undefined }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'B' }) }).catch(() => { });
753
+ // #endregion
754
+ // Note: Content extraction is already done by unified function above
755
+ // This is fallback only if unified function didn't find content
756
+ if (!outputText && firstChoice.message) {
757
+ const content = firstChoice.message.content;
758
+ if (typeof content === 'string' && content.trim()) {
759
+ outputText = content;
760
+ }
761
+ else if (content !== null && content !== undefined) {
762
+ // Handle non-string content (array, object, etc.)
763
+ if (Array.isArray(content)) {
764
+ const textParts = [];
765
+ for (const part of content) {
766
+ if (typeof part === 'string' && part.trim()) {
767
+ textParts.push(part);
768
+ }
769
+ else if (part && typeof part === 'object' && part.text && typeof part.text === 'string' && part.text.trim()) {
770
+ textParts.push(part.text);
771
+ }
772
+ }
773
+ if (textParts.length > 0) {
774
+ outputText = textParts.join('\n');
775
+ }
776
+ }
777
+ else if (typeof content === 'object') {
778
+ const str = String(content);
779
+ if (str && str !== '[object Object]' && str.trim()) {
780
+ outputText = str;
781
+ }
782
+ }
783
+ }
784
+ }
785
+ // #region agent log
786
+ console.log('[DEBUG] After content extraction:', {
787
+ outputTextExtracted: !!outputText,
788
+ outputTextLength: outputText?.length || 0,
789
+ outputTextPreview: outputText?.substring(0, 50) || null,
790
+ });
791
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:770', message: 'After content extraction', data: { outputTextExtracted: !!outputText, outputTextLength: outputText?.length || 0, outputTextPreview: outputText?.substring(0, 50) || null }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'B' }) }).catch(() => { });
792
+ // #endregion
793
+ // Safety guard: if choices exist but outputText is still empty, log warning
794
+ if (!outputText && choices.length > 0) {
795
+ console.warn('[OpenRouterAdapter] Choices exist but content extraction failed', {
796
+ choicesLength: choices.length,
797
+ firstChoice: {
798
+ hasMessage: !!firstChoice.message,
799
+ messageKeys: firstChoice.message ? Object.keys(firstChoice.message) : [],
800
+ content: firstChoice.message?.content,
801
+ contentType: typeof firstChoice.message?.content,
802
+ }
803
+ });
804
+ // #region agent log
805
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:785', message: 'Content extraction failed warning', data: { choicesLength: choices.length, firstChoiceKeys: Object.keys(firstChoice || {}), messageKeys: firstChoice?.message ? Object.keys(firstChoice.message) : [], content: firstChoice?.message?.content, contentType: typeof firstChoice?.message?.content }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'C' }) }).catch(() => { });
806
+ // #endregion
807
+ }
808
+ // Use choices from the source we found (for consistency)
809
+ const choicesToUse = choices;
810
+ // Extract usage from Chat Completions format
811
+ const usageSource = rawResponse?.usage || execResult.rawMeta?.originalResponse?.usage || originalResponse?.usage;
812
+ if (usageSource) {
813
+ usage = {
814
+ inputTokens: usageSource.prompt_tokens || usageSource.input_tokens,
815
+ outputTokens: usageSource.completion_tokens || usageSource.output_tokens,
816
+ totalTokens: usageSource.total_tokens,
817
+ };
818
+ }
819
+ // ✅ Parse reasoning_details from Chat Completions format (choices[].message.reasoning_details[])
820
+ // This is the standard OpenRouter cross-vendor format
821
+ const encryptedArtifacts = [];
822
+ let summaryText;
823
+ let traceText;
824
+ // Check all choices for reasoning_details (not just first)
825
+ for (const choice of choicesToUse || []) {
826
+ // Also capture plaintext reasoning if provider returns it in message.reasoning
827
+ const msgReasoning = choice.message?.reasoning;
828
+ if (typeof msgReasoning === 'string' && msgReasoning.trim()) {
829
+ traceText = traceText ? `${traceText}\n${msgReasoning}` : msgReasoning;
830
+ }
831
+ const reasoningDetails = choice.message?.reasoning_details;
832
+ if (Array.isArray(reasoningDetails)) {
833
+ for (const d of reasoningDetails) {
834
+ if (d.type === 'reasoning.summary') {
835
+ summaryText = d.summary ?? d.text ?? summaryText;
836
+ }
837
+ if (d.type === 'reasoning.text') {
838
+ // Append trace text (may be multiple chunks)
839
+ traceText = traceText ? `${traceText}\n${d.text}` : d.text;
840
+ }
841
+ if (d.type === 'reasoning.encrypted') {
842
+ encryptedArtifacts.push({
843
+ format: 'openai-responses-v1', // Format may vary by vendor, but we normalize
844
+ id: d.id,
845
+ index: d.index,
846
+ data: d.data ?? d.encrypted ?? d.text,
847
+ });
848
+ }
849
+ }
850
+ }
851
+ }
852
+ // IMPORTANT: Check if output array exists and contains Responses API format items
853
+ // Even in Chat Completions response, OpenRouter may include output array with reasoning artifacts
854
+ // This handles Responses API v1 format (openai-responses-v1, xai-responses-v1, etc.)
855
+ const outputArraySource = rawResponse?.output || execResult.rawMeta?.originalResponse?.output || originalResponse?.output;
856
+ if (Array.isArray(outputArraySource)) {
857
+ for (const item of outputArraySource) {
858
+ if (item.type === 'reasoning.encrypted') {
859
+ encryptedArtifacts.push({
860
+ format: rawResponse?.format || originalResponse?.format || 'openai-responses-v1',
861
+ id: item.id,
862
+ index: item.index,
863
+ data: item.data,
864
+ });
865
+ }
866
+ else if (item.type === 'reasoning.summary') {
867
+ summaryText = item.summary ?? item.text ?? summaryText;
868
+ }
869
+ else if (item.type === 'reasoning.text') {
870
+ // Append trace text (may be multiple chunks)
871
+ traceText = traceText ? `${traceText}\n${item.text}` : item.text;
872
+ }
873
+ }
874
+ }
875
+ // Build reasoning response (ONLY source of truth)
876
+ const actualCapabilities = reasoningRequest?.capabilities || {
877
+ supportsEffort: false,
878
+ supportsMaxTokens: false,
879
+ supportsSummary: false,
880
+ supportsTrace: false,
881
+ supportsEncrypted: false,
882
+ supportsReasoningDetails: false,
883
+ };
884
+ reasoning = this.buildReasoningResponse(reasoningRequest ? { ...reasoningRequest, capabilities: actualCapabilities } : undefined, { encrypted: encryptedArtifacts, summaryText, traceText });
885
+ }
886
+ else {
887
+ // Fallback for unknown formats
888
+ console.log('[DEBUG] No choices found - using fallback reasoning response');
889
+ reasoning = this.buildReasoningResponse(reasoningRequest, { encrypted: [] });
890
+ }
891
+ }
892
+ // STRICT MODE: if caller asked to crash when trace is unavailable, enforce it.
893
+ // This covers the "provider returned no trace artifacts" case (not just capability gating).
894
+ // Note: buildReasoningResponse already checks this, but we add an additional safety check here
895
+ // to ensure we catch any edge cases where reasoning was built but artifacts are missing.
896
+ if (reasoningRequest?.requested?.visibility === 'trace' &&
897
+ reasoningRequest?.requested?.onUnsupported === 'error') {
898
+ const hasTraceArtifact = typeof reasoning?.artifacts?.trace === 'object' &&
899
+ typeof reasoning.artifacts.trace?.text === 'string' &&
900
+ reasoning.artifacts.trace.text.trim().length > 0;
901
+ const hasEncrypted = Array.isArray(reasoning?.artifacts?.encrypted) &&
902
+ reasoning.artifacts.encrypted.length > 0;
903
+ if (!hasTraceArtifact && !hasEncrypted) {
904
+ throw new Error("Reasoning request failed: trace was requested but no trace/encrypted reasoning artifacts were returned by provider");
905
+ }
906
+ }
907
+ // PRODUCTION-GRADE FIX: Final validation - ensure outputText is always set
908
+ // The unified extraction function should have found content, but if not, log detailed error
909
+ if (!outputText || outputText.trim() === '') {
910
+ console.error('[OpenRouterAdapter] CRITICAL: Failed to extract content from response', {
911
+ hasRawResponse: !!rawResponse,
912
+ rawResponseKeys: rawResponse ? Object.keys(rawResponse) : [],
913
+ hasChoices: !!(rawResponse?.choices || execResult.rawMeta?.originalResponse?.choices),
914
+ choicesLength: rawResponse?.choices?.length || execResult.rawMeta?.originalResponse?.choices?.length || 0,
915
+ hasOutputArray: !!(rawResponse?.output || execResult.rawMeta?.originalResponse?.output),
916
+ outputArrayLength: Array.isArray(rawResponse?.output) ? rawResponse.output.length :
917
+ (Array.isArray(execResult.rawMeta?.originalResponse?.output) ?
918
+ execResult.rawMeta.originalResponse.output.length : 0),
919
+ isResponsesAPIFormat,
920
+ selectedOutputArrayLength: outputArray.length,
921
+ });
922
+ // Set to empty string to ensure consistent response structure
923
+ outputText = '';
924
+ }
925
+ // #region agent log
926
+ console.log('[DEBUG] Before building response:', {
927
+ hasOutputText: !!outputText,
928
+ outputTextLength: outputText?.length || 0,
929
+ outputTextPreview: outputText?.substring(0, 50) || null,
930
+ hasUsage: !!usage,
931
+ });
932
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:870', message: 'Before building response', data: { hasOutputText: !!outputText, outputTextLength: outputText?.length || 0, outputTextPreview: outputText?.substring(0, 50) || null, hasUsage: !!usage }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'E' }) }).catch(() => { });
933
+ // #endregion
934
+ // Add status and normalize response format for compatibility
935
+ // IMPORTANT: Preserve original output array if it exists (for reasoning artifacts)
936
+ // OpenRouter may return both choices (normalized) and output (original Responses API format)
937
+ // Use the same outputArray we determined during parsing (already has correct priority logic applied)
938
+ const responseWithStatus = {
939
+ ...rawResponse,
940
+ status: 'completed', // Responses are completed if they succeed
941
+ // Use the outputArray we selected during parsing (already has correct source priority)
942
+ // If outputArray is empty, reconstruct from choices for compatibility
943
+ output: outputArray.length > 0
944
+ ? outputArray // Use the output array we selected during parsing
945
+ : rawResponse?.choices ? rawResponse.choices.map((choice) => ({
946
+ content: choice.message?.content ? { text: choice.message.content } : null,
947
+ role: choice.message?.role,
948
+ tool_calls: choice.message?.tool_calls,
949
+ })) : [],
950
+ // Also preserve format field if present
951
+ format: rawResponse?.format || (outputArray.length > 0 ? 'openai-responses-v1' : undefined),
952
+ };
953
+ // #region agent log
954
+ console.log('[DEBUG] responseWithStatus created:', {
955
+ finalOutputLength: Array.isArray(responseWithStatus.output) ? responseWithStatus.output.length : 'not-array',
956
+ finalFormat: responseWithStatus.format,
957
+ usedOutputArray: outputArray.length > 0,
958
+ outputArrayLength: outputArray.length,
959
+ finalOutputItems: Array.isArray(responseWithStatus.output) && responseWithStatus.output.length > 0
960
+ ? responseWithStatus.output.slice(0, 3).map((i) => ({ type: i?.type, keys: i ? Object.keys(i) : [] }))
961
+ : [],
962
+ });
963
+ // #endregion
964
+ // #region agent log
965
+ console.log('[DEBUG] responseWithStatus created:', {
966
+ finalOutputLength: Array.isArray(responseWithStatus.output) ? responseWithStatus.output.length : 'not-array',
967
+ finalFormat: responseWithStatus.format,
968
+ finalOutputItems: Array.isArray(responseWithStatus.output) && responseWithStatus.output.length > 0
969
+ ? responseWithStatus.output.slice(0, 3).map((i) => ({ type: i?.type, keys: i ? Object.keys(i) : [] }))
970
+ : [],
971
+ });
972
+ // #endregion
973
+ // #region agent log
974
+ console.log('[DEBUG] Returning response:', {
975
+ hasOutputText: !!outputText,
976
+ outputTextLength: outputText?.length || 0,
977
+ outputTextPreview: outputText?.substring(0, 50) || null,
978
+ hasUsage: !!usage,
979
+ hasReasoning: !!reasoning,
980
+ provider: 'openrouter',
981
+ });
982
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'OpenRouterAdapter.ts:940', message: 'Returning response', data: { hasOutputText: !!outputText, outputTextLength: outputText?.length || 0, outputTextPreview: outputText?.substring(0, 50) || null, hasUsage: !!usage, hasReasoning: !!reasoning, provider: 'openrouter' }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'E' }) }).catch(() => { });
983
+ // #endregion
984
+ // Build complete response object for ai-activities storage
985
+ const fullResponse = {
986
+ requestId,
987
+ provider: 'openrouter',
988
+ rawResponse: responseWithStatus,
989
+ outputText,
990
+ usage,
991
+ reasoning,
992
+ metadata: {
993
+ model: rawResponse?.model,
994
+ id: rawResponse?.id,
995
+ format: rawResponse?.format,
996
+ ...execResult.rawMeta,
997
+ },
998
+ };
999
+ // Build metadata with ai-activities fields
1000
+ // IMPORTANT: Create a clean copy of fullResponse without circular reference
1001
+ // to avoid serialization issues when storing in metadata
1002
+ const fullResponseForActivities = {
1003
+ requestId: fullResponse.requestId,
1004
+ provider: fullResponse.provider,
1005
+ rawResponse: fullResponse.rawResponse,
1006
+ outputText: fullResponse.outputText,
1007
+ usage: fullResponse.usage,
1008
+ reasoning: fullResponse.reasoning,
1009
+ // Don't include metadata in the copy to avoid circular reference
1010
+ };
1011
+ const metadataWithActivities = {
1012
+ ...fullResponse.metadata,
1013
+ // Include full response in metadata for ai-activities storage (without circular ref)
1014
+ 'ai-activities-response': fullResponseForActivities,
1015
+ // Include request for complete audit trail
1016
+ 'ai-activities-request': request,
1017
+ // Also include original raw response and exec result for complete audit trail
1018
+ 'ai-activities-raw-response': rawResponse,
1019
+ 'ai-activities-original-response': originalResponse,
1020
+ 'ai-activities-exec-meta': execResult.rawMeta,
1021
+ };
1022
+ // #region agent log
1023
+ const logDataA = { location: 'OpenRouterAdapter.ts:1091', message: 'Building response with ai-activities metadata', data: { hasFullResponse: !!fullResponse, hasRequest: !!request, hasRawResponse: !!rawResponse, metadataKeys: Object.keys(metadataWithActivities), hasAiActivitiesResponse: !!metadataWithActivities['ai-activities-response'], hasAiActivitiesRequest: !!metadataWithActivities['ai-activities-request'], aiActivitiesKeys: Object.keys(metadataWithActivities).filter(k => k.startsWith('ai-activities')), metadataSample: Object.keys(metadataWithActivities).slice(0, 10), metadataWithActivitiesKeys: Object.keys(metadataWithActivities) }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'A' };
1024
+ console.log('[DEBUG OpenRouterAdapter] Building ai-activities metadata:', JSON.stringify(logDataA, null, 2));
1025
+ console.log('[DEBUG OpenRouterAdapter] Full metadata keys:', Object.keys(metadataWithActivities));
1026
+ console.log('[DEBUG OpenRouterAdapter] ai-activities keys:', Object.keys(metadataWithActivities).filter(k => k.startsWith('ai-activities')));
1027
+ console.log('[DEBUG OpenRouterAdapter] metadataWithActivities object:', JSON.stringify(metadataWithActivities, null, 2));
1028
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(logDataA) }).catch(() => { });
1029
+ // #endregion
1030
+ const finalResponse = {
1031
+ ...fullResponse,
1032
+ metadata: metadataWithActivities,
1033
+ };
1034
+ // #region agent log
1035
+ const logDataB = { location: 'OpenRouterAdapter.ts:1125', message: 'Returning response from parseResponse', data: { hasMetadata: !!finalResponse.metadata, metadataKeys: finalResponse.metadata ? Object.keys(finalResponse.metadata) : [], hasAiActivitiesResponse: !!finalResponse.metadata?.['ai-activities-response'], hasAiActivitiesRequest: !!finalResponse.metadata?.['ai-activities-request'], responseKeys: Object.keys(finalResponse), metadataStringified: JSON.stringify(finalResponse.metadata) }, timestamp: Date.now(), sessionId: 'debug-session', runId: 'run1', hypothesisId: 'B' };
1036
+ console.log('[DEBUG OpenRouterAdapter] Returning response:', JSON.stringify(logDataB, null, 2));
1037
+ console.log('[DEBUG OpenRouterAdapter] Final response metadata:', JSON.stringify(finalResponse.metadata, null, 2));
1038
+ console.log('[DEBUG OpenRouterAdapter] Final response metadata keys:', finalResponse.metadata ? Object.keys(finalResponse.metadata) : 'NO METADATA');
1039
+ fetch('http://127.0.0.1:7243/ingest/0d4596d8-b8fa-4ca1-a4f4-2e6f23baa429', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(logDataB) }).catch(() => { });
1040
+ // #endregion
1041
+ return finalResponse;
1042
+ }
1043
+ /**
1044
+ * Comprehensive content extraction function
1045
+ * Tries all possible locations and formats to extract text content
1046
+ * Returns the extracted text or undefined if not found
1047
+ */
1048
+ extractContentFromResponse(rawResponse, originalResponse, execResult, outputArray) {
1049
+ // Strategy 1: Extract from Responses API format output array
1050
+ if (outputArray && outputArray.length > 0) {
1051
+ for (const item of outputArray) {
1052
+ // Direct text item
1053
+ if (item.type === 'text' && item.content) {
1054
+ if (typeof item.content === 'string' && item.content.trim()) {
1055
+ return item.content;
1056
+ }
1057
+ }
1058
+ // Message item with content array
1059
+ if (item.type === 'message' && item.content) {
1060
+ if (Array.isArray(item.content)) {
1061
+ for (const contentItem of item.content) {
1062
+ // Text content items
1063
+ if (contentItem.type === 'text' && contentItem.text && typeof contentItem.text === 'string') {
1064
+ return contentItem.text;
1065
+ }
1066
+ if (contentItem.type === 'output_text' && contentItem.text && typeof contentItem.text === 'string') {
1067
+ return contentItem.text;
1068
+ }
1069
+ if (contentItem.type === 'text' && contentItem.content && typeof contentItem.content === 'string') {
1070
+ return contentItem.content;
1071
+ }
1072
+ // String content without type
1073
+ if (!contentItem.type && typeof contentItem === 'string' && contentItem.trim()) {
1074
+ return contentItem;
1075
+ }
1076
+ if (!contentItem.type && contentItem.text && typeof contentItem.text === 'string' && contentItem.text.trim()) {
1077
+ return contentItem.text;
1078
+ }
1079
+ }
1080
+ }
1081
+ else if (typeof item.content === 'string' && item.content.trim()) {
1082
+ return item.content;
1083
+ }
1084
+ else if (item.content && typeof item.content === 'object' && item.content.text && typeof item.content.text === 'string' && item.content.text.trim()) {
1085
+ return item.content.text;
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ // Strategy 2: Extract from Chat Completions format choices
1091
+ const choicesSources = [
1092
+ rawResponse?.choices,
1093
+ execResult.rawMeta?.originalResponse?.choices,
1094
+ execResult.rawMeta?.rawResponse?.choices,
1095
+ originalResponse?.choices,
1096
+ ];
1097
+ for (const choices of choicesSources) {
1098
+ if (Array.isArray(choices) && choices.length > 0) {
1099
+ for (const choice of choices) {
1100
+ if (choice.message) {
1101
+ const content = choice.message.content;
1102
+ // String content
1103
+ if (typeof content === 'string' && content.trim()) {
1104
+ return content;
1105
+ }
1106
+ // Array content (multimodal - extract text parts)
1107
+ if (Array.isArray(content)) {
1108
+ const textParts = [];
1109
+ for (const part of content) {
1110
+ if (typeof part === 'string' && part.trim()) {
1111
+ textParts.push(part);
1112
+ }
1113
+ else if (part && typeof part === 'object') {
1114
+ if (part.type === 'text' && part.text && typeof part.text === 'string' && part.text.trim()) {
1115
+ textParts.push(part.text);
1116
+ }
1117
+ else if (part.text && typeof part.text === 'string' && part.text.trim()) {
1118
+ textParts.push(part.text);
1119
+ }
1120
+ }
1121
+ }
1122
+ if (textParts.length > 0) {
1123
+ return textParts.join('\n');
1124
+ }
1125
+ }
1126
+ // Object content (try to extract text)
1127
+ if (content && typeof content === 'object' && !Array.isArray(content)) {
1128
+ if (content.text && typeof content.text === 'string' && content.text.trim()) {
1129
+ return content.text;
1130
+ }
1131
+ // Try to stringify if it's a simple object
1132
+ try {
1133
+ const str = String(content);
1134
+ if (str && str !== '[object Object]' && str.trim()) {
1135
+ return str;
1136
+ }
1137
+ }
1138
+ catch {
1139
+ // Ignore
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+ }
1145
+ }
1146
+ // Strategy 3: Direct text fields
1147
+ const textFields = [
1148
+ rawResponse?.output_text,
1149
+ rawResponse?.text,
1150
+ execResult.rawMeta?.originalResponse?.output_text,
1151
+ execResult.rawMeta?.originalResponse?.text,
1152
+ originalResponse?.output_text,
1153
+ originalResponse?.text,
1154
+ ];
1155
+ for (const text of textFields) {
1156
+ if (typeof text === 'string' && text.trim()) {
1157
+ return text;
1158
+ }
1159
+ }
1160
+ // Strategy 4: Check rawResponse.output for text (non-array case)
1161
+ if (rawResponse?.output && typeof rawResponse.output === 'string' && rawResponse.output.trim()) {
1162
+ return rawResponse.output;
1163
+ }
1164
+ if (originalResponse?.output && typeof originalResponse.output === 'string' && originalResponse.output.trim()) {
1165
+ return originalResponse.output;
1166
+ }
1167
+ return undefined;
1168
+ }
1169
+ /**
1170
+ * Build unified reasoning response from request info and parsed artifacts
1171
+ * Implements deterministic downgrade rules based on what was actually returned
1172
+ * This is the ONLY source of truth for reasoning response structure
1173
+ */
1174
+ buildReasoningResponse(reasoningRequest, parsed) {
1175
+ const { encrypted: encryptedArtifacts, summaryText, traceText } = parsed;
1176
+ // Default values
1177
+ const requested = reasoningRequest?.requested || { effort: 'none', visibility: 'none' };
1178
+ const onUnsupported = reasoningRequest?.requested?.onUnsupported || 'downgrade';
1179
+ const capabilities = reasoningRequest?.capabilities || {
1180
+ supportsEffort: false,
1181
+ supportsMaxTokens: false,
1182
+ supportsSummary: false,
1183
+ supportsTrace: false,
1184
+ supportsEncrypted: false,
1185
+ supportsReasoningDetails: false,
1186
+ };
1187
+ // Determine applied values, warnings, and errors
1188
+ // Note: In parseResponse phase, errors should have already been handled in buildCallSpec
1189
+ // But we still convert any remaining errors to warnings here for safety
1190
+ const { applied, warnings, errors } = reasoningRequest
1191
+ ? this.mapReasoningToProvider(reasoningRequest.requested, capabilities)
1192
+ : { applied: { effort: 'none', visibility: 'none' }, warnings: [], errors: [] };
1193
+ // In response parsing phase, convert any remaining errors to warnings (shouldn't happen if buildCallSpec worked correctly)
1194
+ const allWarnings = [...warnings, ...errors.map(error => ({
1195
+ code: error.code,
1196
+ message: error.message
1197
+ }))];
1198
+ // Build artifacts based on what was actually returned
1199
+ const artifacts = {};
1200
+ if (encryptedArtifacts.length > 0) {
1201
+ artifacts.encrypted = encryptedArtifacts;
1202
+ }
1203
+ if (summaryText) {
1204
+ artifacts.summary = { text: summaryText, format: 'text/plain' };
1205
+ }
1206
+ if (traceText) {
1207
+ artifacts.trace = { text: traceText, format: 'text/plain' };
1208
+ }
1209
+ // DETERMINISTIC DOWNGRADE RULES: Adjust applied visibility based on what was actually returned
1210
+ const finalApplied = { ...applied };
1211
+ // Determine availability flags based on what was actually returned
1212
+ const supportsEncrypted = encryptedArtifacts.length > 0;
1213
+ const supportsSummary = !!summaryText;
1214
+ const supportsTrace = encryptedArtifacts.length > 0 || !!traceText; // Either encrypted OR text satisfies trace
1215
+ // Apply visibility based on what was actually returned
1216
+ if (requested.visibility === 'summary') {
1217
+ if (summaryText) {
1218
+ // Summary exists, use it
1219
+ finalApplied.visibility = 'summary';
1220
+ }
1221
+ else {
1222
+ // Summary requested but not returned
1223
+ if (onUnsupported === 'error') {
1224
+ throw new Error('Reasoning visibility=summary was requested, but no summary was returned.');
1225
+ }
1226
+ finalApplied.visibility = 'none';
1227
+ allWarnings.push({
1228
+ code: 'VISIBILITY_DOWNGRADED',
1229
+ message: 'Requested summary visibility but no summary returned, downgraded to none',
1230
+ });
1231
+ }
1232
+ }
1233
+ else if (requested.visibility === 'trace') {
1234
+ // Trace visibility is satisfied by EITHER encrypted artifacts OR readable text
1235
+ if (encryptedArtifacts.length > 0 || traceText) {
1236
+ // Trace exists (encrypted or readable), use it
1237
+ finalApplied.visibility = 'trace';
1238
+ }
1239
+ else {
1240
+ // Trace requested but not returned
1241
+ if (onUnsupported === 'error') {
1242
+ throw new Error('Reasoning visibility=trace was requested, but no trace was returned (no encrypted artifacts or trace text).');
1243
+ }
1244
+ finalApplied.visibility = 'none';
1245
+ allWarnings.push({
1246
+ code: 'VISIBILITY_DOWNGRADED',
1247
+ message: 'Requested trace visibility but no trace returned (no encrypted artifacts or trace text), downgraded to none',
1248
+ });
1249
+ }
1250
+ }
1251
+ // Update capabilities based on what we actually found
1252
+ const actualCapabilities = {
1253
+ ...capabilities,
1254
+ supportsEncrypted,
1255
+ supportsSummary,
1256
+ supportsTrace,
1257
+ };
1258
+ // Log reasoning summary
1259
+ this.logReasoningSummary(requested, finalApplied, artifacts, allWarnings);
1260
+ return {
1261
+ requested,
1262
+ applied: finalApplied,
1263
+ artifacts,
1264
+ availability: actualCapabilities,
1265
+ warnings: allWarnings,
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Log reasoning processing summary
1270
+ */
1271
+ logReasoningSummary(requested, applied, artifacts, warnings) {
1272
+ // Only log if reasoning was actually requested
1273
+ if (requested.effort !== 'none' || requested.visibility !== 'none') {
1274
+ console.log('🧠 Reasoning Processing Summary:');
1275
+ console.log(` Requested: effort=${requested.effort}, visibility=${requested.visibility}`);
1276
+ console.log(` Applied: effort=${applied.effort}, visibility=${applied.visibility}`);
1277
+ if (artifacts.encrypted && artifacts.encrypted.length > 0) {
1278
+ console.log(` 📦 Encrypted artifacts: ${artifacts.encrypted.length} items`);
1279
+ artifacts.encrypted.forEach((artifact, i) => {
1280
+ const dataLen = artifact.data ? (typeof artifact.data === 'string' ? artifact.data.length : JSON.stringify(artifact.data).length) : 0;
1281
+ const dataPrefix = artifact.data && typeof artifact.data === 'string'
1282
+ ? artifact.data.substring(0, Math.min(32, artifact.data.length))
1283
+ : 'N/A';
1284
+ console.log(` [${i}] ${artifact.format} (id: ${artifact.id || 'N/A'}, dataLen=${dataLen}, dataPrefix="${dataPrefix}")`);
1285
+ });
1286
+ }
1287
+ if (warnings && warnings.length > 0) {
1288
+ console.log(` ⚠️ Warnings: ${warnings.length}`);
1289
+ warnings.forEach((warning) => {
1290
+ console.log(` ${warning.code}: ${warning.message}`);
1291
+ });
1292
+ }
1293
+ console.log(''); // Empty line for readability
1294
+ }
1295
+ }
1296
+ parseStreamChunk(input) {
1297
+ const { requestId, chunk } = input;
1298
+ const raw = chunk.raw;
1299
+ const events = [];
1300
+ // Emit provider_raw event
1301
+ events.push({
1302
+ type: 'provider_raw',
1303
+ requestId,
1304
+ provider: 'openrouter',
1305
+ raw,
1306
+ });
1307
+ // Handle OpenAI Responses API streaming format
1308
+ if (raw?.format === 'openai-responses-v1' && Array.isArray(raw.output)) {
1309
+ for (const item of raw.output) {
1310
+ if (item.type === 'text' && item.content) {
1311
+ events.push({
1312
+ type: 'output_text_delta',
1313
+ requestId,
1314
+ delta: item.content,
1315
+ });
1316
+ }
1317
+ // reasoning.encrypted items are streamed but encrypted
1318
+ // Could potentially emit reasoning_trace_delta for encrypted items if needed
1319
+ }
1320
+ }
1321
+ // Parse traditional OpenAI Chat Completions stream chunk format
1322
+ // OpenAI stream format: { choices: [{ delta: { content: "..." } }] }
1323
+ else if (raw?.choices && Array.isArray(raw.choices)) {
1324
+ for (const choice of raw.choices) {
1325
+ if (choice.delta?.content) {
1326
+ events.push({
1327
+ type: 'output_text_delta',
1328
+ requestId,
1329
+ delta: choice.delta.content,
1330
+ });
1331
+ }
1332
+ }
1333
+ }
1334
+ return events;
1335
+ }
1336
+ finalizeStream(input) {
1337
+ const { requestId, collected, finalRaw } = input;
1338
+ // Use finalRaw if available, otherwise use last raw event
1339
+ const rawResponse = finalRaw || (collected.rawEvents.length > 0 ? collected.rawEvents[collected.rawEvents.length - 1] : {});
1340
+ // Try to extract usage from final response
1341
+ let usage;
1342
+ const finalResponse = rawResponse;
1343
+ // Handle both formats for usage extraction
1344
+ if (finalResponse?.usage) {
1345
+ if (finalResponse.format === 'openai-responses-v1') {
1346
+ usage = {
1347
+ inputTokens: finalResponse.usage.input_tokens,
1348
+ outputTokens: finalResponse.usage.output_tokens,
1349
+ totalTokens: finalResponse.usage.total_tokens,
1350
+ };
1351
+ }
1352
+ else {
1353
+ // Traditional Chat Completions format
1354
+ usage = {
1355
+ inputTokens: finalResponse.usage.prompt_tokens,
1356
+ outputTokens: finalResponse.usage.completion_tokens,
1357
+ totalTokens: finalResponse.usage.total_tokens,
1358
+ };
1359
+ }
1360
+ }
1361
+ // For streaming finalize, we don't have access to original reasoning request
1362
+ // Create a basic reasoning response based on available data
1363
+ const encryptedArtifacts = [];
1364
+ // Extract encrypted artifacts from final response if present
1365
+ if (finalResponse?.format === 'openai-responses-v1' && Array.isArray(finalResponse.output)) {
1366
+ for (const item of finalResponse.output) {
1367
+ if (item.type === 'reasoning.encrypted') {
1368
+ encryptedArtifacts.push({
1369
+ format: 'openai-responses-v1',
1370
+ id: item.id,
1371
+ index: item.index,
1372
+ data: item.data,
1373
+ });
1374
+ }
1375
+ }
1376
+ }
1377
+ // Build basic reasoning response (request details not available in streaming finalize)
1378
+ const reasoning = {
1379
+ requested: { effort: 'none', visibility: 'none' },
1380
+ applied: { effort: 'none', visibility: encryptedArtifacts.length > 0 ? 'trace' : 'none' },
1381
+ artifacts: encryptedArtifacts.length > 0 ? { encrypted: encryptedArtifacts } : {},
1382
+ availability: {
1383
+ supportsEffort: false,
1384
+ supportsSummary: false,
1385
+ supportsTrace: false,
1386
+ supportsEncrypted: encryptedArtifacts.length > 0,
1387
+ },
1388
+ };
1389
+ // Build complete response object for ai-activities storage
1390
+ const fullResponse = {
1391
+ requestId,
1392
+ provider: 'openrouter',
1393
+ rawResponse,
1394
+ outputText: collected.outputText,
1395
+ usage,
1396
+ reasoning,
1397
+ metadata: {
1398
+ model: finalResponse?.model,
1399
+ id: finalResponse?.id,
1400
+ format: finalResponse?.format, // Include format for debugging
1401
+ },
1402
+ };
1403
+ // IMPORTANT: Create a clean copy of fullResponse without circular reference
1404
+ const fullResponseForActivities = {
1405
+ requestId: fullResponse.requestId,
1406
+ provider: fullResponse.provider,
1407
+ rawResponse: fullResponse.rawResponse,
1408
+ outputText: fullResponse.outputText,
1409
+ usage: fullResponse.usage,
1410
+ reasoning: fullResponse.reasoning,
1411
+ // Don't include metadata in the copy to avoid circular reference
1412
+ };
1413
+ return {
1414
+ ...fullResponse,
1415
+ metadata: {
1416
+ ...fullResponse.metadata,
1417
+ // Include full response in metadata for ai-activities storage (without circular ref)
1418
+ 'ai-activities-response': fullResponseForActivities,
1419
+ // Include request for complete audit trail
1420
+ 'ai-activities-request': input.request,
1421
+ 'ai-activities-raw-response': rawResponse,
1422
+ 'ai-activities-collected-events': collected.rawEvents,
1423
+ },
1424
+ };
1425
+ }
1426
+ parseBatchItem(input) {
1427
+ const { requestId, item } = input;
1428
+ if (item.error) {
1429
+ return {
1430
+ requestId,
1431
+ error: item.error,
1432
+ };
1433
+ }
1434
+ // Parse OpenAI-compatible batch item response directly (no ai-io-normalizer)
1435
+ const rawResponse = item.rawResponse;
1436
+ let outputText;
1437
+ if (rawResponse?.choices && Array.isArray(rawResponse.choices) && rawResponse.choices.length > 0) {
1438
+ const firstChoice = rawResponse.choices[0];
1439
+ if (firstChoice.message?.content) {
1440
+ outputText = firstChoice.message.content;
1441
+ }
1442
+ }
1443
+ return {
1444
+ requestId,
1445
+ rawResponse,
1446
+ outputText,
1447
+ };
1448
+ }
1449
+ }