@juspay/neurolink 12.12.10 → 12.12.12

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.
@@ -1,754 +0,0 @@
1
- /**
2
- * Generation Handler Module
3
- *
4
- * Handles text generation execution, result formatting, and tool information extraction.
5
- * Extracted from BaseProvider to follow Single Responsibility Principle.
6
- *
7
- * Responsibilities:
8
- * - Generation execution with AI SDK
9
- * - Tool information extraction
10
- * - Result formatting and enhancement
11
- * - Response analysis and logging
12
- *
13
- * @module core/modules/GenerationHandler
14
- */
15
- import { SpanKind, SpanStatusCode } from "@opentelemetry/api";
16
- import { getModelId } from "../../providers/providerTypeUtils.js";
17
- import { tracers } from "../../telemetry/tracers.js";
18
- import { logger } from "../../utils/logger.js";
19
- import { calculateCost } from "../../utils/pricing.js";
20
- import { withProviderRetry } from "../../utils/providerRetry.js";
21
- import { parseTimeout } from "../../utils/timeout.js";
22
- import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
23
- import { DEFAULT_MAX_STEPS, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../constants.js";
24
- import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, } from "./structuredOutputPolicy.js";
25
- import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../../utils/json/coerce.js";
26
- import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
27
- const genTracer = tracers.generation;
28
- /**
29
- * Safely preview-serialize a value for debug logging.
30
- * Handles undefined, circular references, and non-serializable values.
31
- */
32
- function safePreview(v) {
33
- if (v === undefined) {
34
- return "";
35
- }
36
- try {
37
- const text = typeof v === "string" ? v : JSON.stringify(v);
38
- return (text ?? "").substring(0, 200);
39
- }
40
- catch {
41
- return "[unserializable]";
42
- }
43
- }
44
- /**
45
- * Turn budget + wrap-up deadline (parity with the googleVertex native loops).
46
- * A deadline is engaged only when the caller expressed one: turnTimeoutMs
47
- * wins, else an explicit generate timeout. Callers that set neither keep the
48
- * pre-existing behaviour (no wrap-up; the outer defensive timeout in
49
- * executeStandardGenerateFlow still applies). With `wrapupTimeLeadMs` left of
50
- * the deadline, the loop stops offering tools (toolChoice: "none") so the
51
- * model spends the remaining budget producing a final answer instead of being
52
- * guillotined mid-tool-loop with all work discarded. The lead is clamped to a
53
- * quarter of the budget so short explicit timeouts (e.g. 30s) don't trigger
54
- * wrap-up on the very first step.
55
- *
56
- * `turnStartMs` anchors the deadline to the ORIGINAL generation start:
57
- * callGenerateText re-runs on executeGeneration's fallback retries
58
- * (structured-output conflict, temperature-deprecated) and provider retries,
59
- * and a deadline computed from Date.now() per attempt would hand each retry
60
- * a fresh budget — multiplying the caller's wall-clock cap.
61
- */
62
- export function resolveTurnBudget(options, turnStartMs) {
63
- const callerTimeoutMs = parseTimeout(options.timeout);
64
- const hasValidTurnTimeout = typeof options.turnTimeoutMs === "number" &&
65
- Number.isFinite(options.turnTimeoutMs) &&
66
- options.turnTimeoutMs > 0;
67
- if (options.turnTimeoutMs !== undefined && !hasValidTurnTimeout) {
68
- logger.warn("[GenerationHandler] Ignoring invalid turnTimeoutMs — expected a positive number of milliseconds; falling back to the timeout option", { turnTimeoutMs: options.turnTimeoutMs });
69
- }
70
- let turnBudgetMs = hasValidTurnTimeout
71
- ? options.turnTimeoutMs
72
- : callerTimeoutMs;
73
- let wrapupLeadMs = turnBudgetMs
74
- ? Math.min(options.wrapupTimeLeadMs ?? DEFAULT_WRAPUP_TIME_LEAD_MS, Math.floor(turnBudgetMs / 4))
75
- : 0;
76
- // When the budget is DERIVED from the generate `timeout`, the hard abort in
77
- // executeStandardGenerateFlow fires at exactly callerTimeoutMs — the same
78
- // instant as the turn deadline. Wrap-up would engage at (deadline − lead)
79
- // but its final, tools-off generation then RACES the abort and loses on
80
- // slow models (observed: wrap-up engaged at T−lead, final answer killed at
81
- // exactly T → TimeoutError, all work discarded). Pull the turn deadline one
82
- // wrap-up lead earlier so the final generation runs in EXCLUSIVE margin
83
- // before the abort. An explicit turnTimeoutMs is left untouched — the
84
- // caller separated the two deadlines deliberately.
85
- if (!hasValidTurnTimeout && turnBudgetMs !== undefined && wrapupLeadMs > 0) {
86
- turnBudgetMs = turnBudgetMs - wrapupLeadMs;
87
- // Keep the quarter-budget clamp invariant against the reduced budget so
88
- // short timeouts still don't wrap up on the very first step.
89
- wrapupLeadMs = Math.min(wrapupLeadMs, Math.floor(turnBudgetMs / 4));
90
- }
91
- const turnDeadline = turnBudgetMs ? turnStartMs + turnBudgetMs : undefined;
92
- return { callerTimeoutMs, turnBudgetMs, wrapupLeadMs, turnDeadline };
93
- }
94
- /**
95
- * GenerationHandler class - Handles text generation operations for AI providers
96
- */
97
- export class GenerationHandler {
98
- providerName;
99
- modelName;
100
- supportsToolsFn;
101
- getTelemetryConfigFn;
102
- handleToolStorageFn;
103
- deps;
104
- constructor(providerName, modelName, supportsToolsFn, getTelemetryConfigFn, handleToolStorageFn,
105
- /**
106
- * The remaining, optional dependencies.
107
- *
108
- * Grouped rather than added as further positional parameters: the
109
- * constructor is already at the six-parameter cap, and both of these are
110
- * optional injection seams rather than required collaborators.
111
- *
112
- * `generateTextFn` exists because every other dependency of this class
113
- * arrives through the constructor while `generateText` was reached by
114
- * static import. `utils/generation.ts` is a bare re-export of `ai`, and an
115
- * ESM re-export cannot be substituted from a test, so asserting on the
116
- * arguments this class builds — the entire contract of the system-message
117
- * hoisting below — had no seam to work through. Production passes neither.
118
- */
119
- deps = {}) {
120
- this.providerName = providerName;
121
- this.modelName = modelName;
122
- this.supportsToolsFn = supportsToolsFn;
123
- this.getTelemetryConfigFn = getTelemetryConfigFn;
124
- this.handleToolStorageFn = handleToolStorageFn;
125
- this.deps = deps;
126
- }
127
- /**
128
- * Helper method to call generateText with optional structured output
129
- * @private
130
- */
131
- /**
132
- * The ai-package generate loop.
133
- *
134
- * Unreachable: every text provider now implements a native generate() and
135
- * none of them return here. That was established by trapping the seam —
136
- * replacing the ai package's generateText with a throwing stub left the full
137
- * provider matrix passing and zero cells reaching it — and non-text request
138
- * kinds return from runGenerateInActiveContext before this handler is
139
- * consulted.
140
- *
141
- * Kept as an explicit failure rather than deleted outright so a provider
142
- * added without a native generate() fails loudly here instead of silently
143
- * reintroducing a dependency on the removed package.
144
- */
145
- async callGenerateText(_model, _messages, _tools, _options, _callConfig) {
146
- throw new Error("GenerationHandler.callGenerateText is no longer implemented: every provider must supply a native generate(). See docs/plans/2026-09-03-completing-the-ai-sdk-removal.md");
147
- }
148
- async executeGeneration(model, messages, tools, options) {
149
- return genTracer.startActiveSpan("neurolink.executeGeneration", { kind: SpanKind.INTERNAL }, async (span) => {
150
- const shouldUseTools = !options.disableTools && this.supportsToolsFn();
151
- const toolCount = Object.keys(tools || {}).length;
152
- const useStructuredOutput = !!options.schema ||
153
- options.output?.format === "json" ||
154
- options.output?.format === "structured";
155
- span.setAttribute("gen_ai.system", this.providerName || "unknown");
156
- span.setAttribute("neurolink.structured_output", useStructuredOutput);
157
- span.setAttribute("neurolink.tool_count", toolCount);
158
- span.setAttribute("neurolink.message_count", messages.length);
159
- span.setAttribute("gen_ai.request.model", getModelId(model, this.modelName || "unknown"));
160
- const requestId = options.requestId ||
161
- options.context?.requestId ||
162
- "unknown";
163
- logger.info("[GenerationHandler] Calling generateText", {
164
- requestId,
165
- model: getModelId(model),
166
- messageCount: messages.length,
167
- toolCount,
168
- maxSteps: options.maxSteps,
169
- temperature: options.temperature,
170
- });
171
- if (logger.shouldLog("debug")) {
172
- try {
173
- logger.debug("[Observability] Full generateText parameters", {
174
- requestId,
175
- model: getModelId(model),
176
- messageCount: messages.length,
177
- messages: messages.map((msg, i) => ({
178
- index: i,
179
- role: msg.role,
180
- contentLength: typeof msg.content === "string"
181
- ? msg.content.length
182
- : safePreview(msg.content).length,
183
- contentPreview: typeof msg.content === "string"
184
- ? msg.content.substring(0, 200)
185
- : "[multimodal]",
186
- })),
187
- toolNames: Object.keys(tools || {}),
188
- toolCount,
189
- maxSteps: options.maxSteps,
190
- temperature: options.temperature,
191
- maxTokens: options.maxTokens,
192
- });
193
- }
194
- catch {
195
- // Ignore serialization errors in debug logging
196
- }
197
- }
198
- const genStartTime = Date.now();
199
- try {
200
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, {
201
- shouldUseTools,
202
- includeStructuredOutput: true,
203
- turnStartMs: genStartTime,
204
- }), span, "generateText");
205
- logger.info("[GenerationHandler] generateText returned", {
206
- requestId,
207
- durationMs: Date.now() - genStartTime,
208
- finishReason: result.finishReason,
209
- steps: result.steps?.length || 1,
210
- toolCallsTotal: result.toolCalls?.length || 0,
211
- responseChars: result.text?.length || 0,
212
- });
213
- if (logger.shouldLog("debug")) {
214
- logger.debug("[Observability] LLM response metadata", {
215
- requestId,
216
- responseLength: result.text?.length || 0,
217
- hasToolCalls: !!(result.toolCalls && result.toolCalls.length > 0),
218
- toolCallCount: result.toolCalls?.length || 0,
219
- toolNames: result.toolCalls?.map((tc) => tc.toolName),
220
- finishReason: result.finishReason,
221
- stepCount: result.steps?.length || 0,
222
- steps: result.steps?.map((step, i) => ({
223
- stepIndex: i,
224
- stepType: step.stepType,
225
- textLength: step.text?.length || 0,
226
- toolCallCount: step.toolCalls?.length || 0,
227
- toolNames: step.toolCalls?.map((tc) => tc.toolName),
228
- toolResultCount: step.toolResults?.length || 0,
229
- finishReason: step.finishReason,
230
- })),
231
- usage: result.usage,
232
- });
233
- }
234
- // Set token usage and completion attributes on span
235
- this.setUsageSpanAttributes(span, result);
236
- if (result.finishReason) {
237
- span.setAttribute("gen_ai.response.finish_reason", result.finishReason);
238
- }
239
- span.setStatus({ code: SpanStatusCode.OK });
240
- return result;
241
- }
242
- catch (error) {
243
- // Fall back to text-mode (no experimental_output) when structured
244
- // output + tools failed, in three cases:
245
- // 1. NoObjectGeneratedError — the SDK couldn't coerce the object.
246
- // 2. The provider rejected json-mode-with-tools outright (e.g. Groq:
247
- // "json mode cannot be combined with tool/function calling").
248
- // 3. The provider rejected the schema as too complex for its
249
- // constrained decoding (Vertex Gemini 400 "too many states") —
250
- // deterministic, so re-sending the schema can never succeed.
251
- // In all cases we retry without structured output and let
252
- // formatEnhancedResult coerce the text response into valid JSON.
253
- const schemaTooComplex = useStructuredOutput && isSchemaComplexityError(error);
254
- const isStructuredOutputConflict = useStructuredOutput &&
255
- (error instanceof NoObjectGeneratedError ||
256
- isToolsSchemaConflictError(error) ||
257
- schemaTooComplex);
258
- if (isStructuredOutputConflict) {
259
- span.setAttribute("neurolink.has_fallback", true);
260
- // NLK-GAP-007: Record initial failure event before fallback retry
261
- span.addEvent("retry.initial_failure", {
262
- "error.message": error instanceof Error ? error.message : String(error),
263
- "retry.attempt": 1,
264
- "retry.reason": error instanceof NoObjectGeneratedError
265
- ? "NoObjectGeneratedError_structured_output_fallback"
266
- : schemaTooComplex
267
- ? "schema_complexity_structured_output_fallback"
268
- : "tools_schema_conflict_structured_output_fallback",
269
- });
270
- if (schemaTooComplex) {
271
- // warn (not debug): callers should simplify their schema — the
272
- // fallback keeps the turn alive but skips native enforcement.
273
- logger.warn("[GenerationHandler] schema too complex for provider constrained decoding — retrying with prompt-based JSON", {
274
- provider: this.providerName,
275
- model: this.modelName,
276
- error: error instanceof Error ? error.message : String(error),
277
- });
278
- }
279
- else {
280
- logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
281
- provider: this.providerName,
282
- model: this.modelName,
283
- error: error instanceof Error ? error.message : String(error),
284
- });
285
- }
286
- // Retry without experimental_output - the formatEnhancedResult method
287
- // will extract JSON from the text response
288
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, options, {
289
- shouldUseTools,
290
- // includeStructuredOutput intentionally omitted
291
- includeStructuredOutput: false,
292
- turnStartMs: genStartTime,
293
- }), span, "generateText(fallback)");
294
- // NLK-GAP-007: Record recovery event after successful fallback
295
- span.addEvent("retry.recovered", {
296
- "retry.attempts": 2,
297
- "retry.strategy": "structured_output_disabled",
298
- });
299
- span.setAttribute("retry.count", 1);
300
- logger.info("[GenerationHandler] generateText returned (fallback)", {
301
- requestId,
302
- durationMs: Date.now() - genStartTime,
303
- finishReason: result.finishReason,
304
- steps: result.steps?.length || 1,
305
- toolCallsTotal: result.toolCalls?.length || 0,
306
- responseChars: result.text?.length || 0,
307
- });
308
- this.setUsageSpanAttributes(span, result);
309
- if (result.finishReason) {
310
- span.setAttribute("gen_ai.response.finish_reason", result.finishReason);
311
- }
312
- span.setStatus({ code: SpanStatusCode.OK });
313
- return result;
314
- }
315
- // Retry once without `temperature` when the model deprecated it. The
316
- // newest Anthropic models (e.g. claude-opus-4-8 with tools + advanced
317
- // beta features) reject `temperature` — "`temperature` is deprecated
318
- // for this model." — in favour of reasoning-effort controls. Structured
319
- // output is already excluded for the native anthropic surface, so this
320
- // is the dominant failure mode for Opus there.
321
- if (isTemperatureDeprecatedError(error) &&
322
- typeof options.temperature === "number") {
323
- span.setAttribute("neurolink.has_fallback", true);
324
- span.addEvent("retry.initial_failure", {
325
- "error.message": error instanceof Error ? error.message : String(error),
326
- "retry.attempt": 1,
327
- "retry.reason": "temperature_deprecated",
328
- });
329
- logger.debug("[GenerationHandler] temperature-deprecated error caught - retrying without temperature", {
330
- provider: this.providerName,
331
- model: this.modelName,
332
- error: error instanceof Error ? error.message : String(error),
333
- });
334
- const result = await withProviderRetry(() => this.callGenerateText(model, messages, tools, { ...options, temperature: undefined }, {
335
- shouldUseTools,
336
- // mirror the initial call; the structured-output policy still applies
337
- includeStructuredOutput: true,
338
- turnStartMs: genStartTime,
339
- }), span, "generateText(no-temperature)");
340
- span.addEvent("retry.recovered", {
341
- "retry.attempts": 2,
342
- "retry.strategy": "temperature_omitted",
343
- });
344
- span.setAttribute("retry.count", 1);
345
- this.setUsageSpanAttributes(span, result);
346
- if (result.finishReason) {
347
- span.setAttribute("gen_ai.response.finish_reason", result.finishReason);
348
- }
349
- span.setStatus({ code: SpanStatusCode.OK });
350
- return result;
351
- }
352
- span.setStatus({
353
- code: SpanStatusCode.ERROR,
354
- message: error instanceof Error ? error.message : String(error),
355
- });
356
- // Re-throw other errors
357
- throw error;
358
- }
359
- finally {
360
- span.end();
361
- }
362
- });
363
- }
364
- /**
365
- * Extract cache metrics from provider metadata (e.g. Anthropic's providerMetadata.anthropic)
366
- * The AI SDK's LanguageModelUsage only has inputTokens/outputTokens.
367
- * Cache metrics are surfaced via providerMetadata by provider-specific SDK adapters.
368
- */
369
- /**
370
- * Set gen_ai usage attributes + cache-aware cost on the span from the
371
- * CROSS-STEP aggregate (result.totalUsage). result.usage is the LAST step
372
- * only — using it undercounted every multi-step tool loop, and pricing the
373
- * raw cache-inclusive inputTokens without the cache fields billed cache
374
- * reads at the full input rate.
375
- */
376
- setUsageSpanAttributes(span, result) {
377
- const aggregate = result.totalUsage ?? result.usage;
378
- if (!aggregate) {
379
- return;
380
- }
381
- span.setAttribute("gen_ai.usage.input_tokens", aggregate.inputTokens || 0);
382
- span.setAttribute("gen_ai.usage.output_tokens", aggregate.outputTokens || 0);
383
- // Cost on span so users can query "what did this trace cost?" —
384
- // extractTokenUsage rebases input onto the uncached remainder and
385
- // surfaces the cache fields so calculateCost prices each tier.
386
- const cost = calculateCost(this.providerName, this.modelName, extractTokenUsage(aggregate));
387
- span.setAttribute("neurolink.cost", cost ?? 0);
388
- }
389
- extractCacheMetricsFromProviderMetadata(generateResult) {
390
- const providerMeta = generateResult.providerMetadata;
391
- if (!providerMeta) {
392
- return {};
393
- }
394
- // Anthropic surfaces cache metrics under providerMetadata.anthropic
395
- const anthropicMeta = providerMeta.anthropic;
396
- if (anthropicMeta) {
397
- const cacheCreationTokens = extractCacheCreationTokens(anthropicMeta);
398
- const cacheReadTokens = extractCacheReadTokens(anthropicMeta);
399
- return {
400
- ...(cacheCreationTokens !== undefined && { cacheCreationTokens }),
401
- ...(cacheReadTokens !== undefined && { cacheReadTokens }),
402
- };
403
- }
404
- return {};
405
- }
406
- /**
407
- * Log generation completion information
408
- */
409
- logGenerationComplete(generateResult) {
410
- const cacheMetrics = this.extractCacheMetricsFromProviderMetadata(generateResult);
411
- if (logger.shouldLog("debug")) {
412
- logger.debug(`generateText completed`, {
413
- provider: this.providerName,
414
- model: this.modelName,
415
- responseLength: generateResult.text?.length || 0,
416
- toolResultsCount: generateResult.toolResults?.length || 0,
417
- finishReason: generateResult.finishReason,
418
- usage: generateResult.usage,
419
- ...(cacheMetrics.cacheCreationTokens !== undefined && {
420
- cacheCreationTokens: cacheMetrics.cacheCreationTokens,
421
- }),
422
- ...(cacheMetrics.cacheReadTokens !== undefined && {
423
- cacheReadTokens: cacheMetrics.cacheReadTokens,
424
- }),
425
- timestamp: Date.now(),
426
- });
427
- }
428
- }
429
- /**
430
- * Extract tool information from generation result
431
- */
432
- extractToolInformation(generateResult) {
433
- const toolsUsed = [];
434
- const toolExecutions = [];
435
- // Extract tool names from tool calls
436
- if (generateResult.toolCalls && generateResult.toolCalls.length > 0) {
437
- toolsUsed.push(...generateResult.toolCalls.map((tc) => {
438
- return tc.toolName || tc.name || "unknown";
439
- }));
440
- }
441
- // Extract from steps
442
- if (generateResult.steps && Array.isArray(generateResult.steps)) {
443
- const toolCallArgsMap = new Map();
444
- for (const step of generateResult.steps || []) {
445
- // Collect tool calls and their arguments
446
- if (step?.toolCalls && Array.isArray(step.toolCalls)) {
447
- for (const toolCall of step.toolCalls) {
448
- const tcRecord = toolCall;
449
- const toolName = tcRecord.toolName ||
450
- tcRecord.name ||
451
- "unknown";
452
- const toolId = tcRecord.toolCallId ||
453
- tcRecord.id ||
454
- toolName;
455
- toolsUsed.push(toolName);
456
- let callArgs = {};
457
- if (tcRecord.input) {
458
- // AI SDK v6 carries tool-call arguments as `input`.
459
- callArgs = tcRecord.input;
460
- }
461
- else if (tcRecord.args) {
462
- callArgs = tcRecord.args;
463
- }
464
- else if (tcRecord.arguments) {
465
- callArgs = tcRecord.arguments;
466
- }
467
- else if (tcRecord.parameters) {
468
- callArgs = tcRecord.parameters;
469
- }
470
- toolCallArgsMap.set(toolId, callArgs);
471
- toolCallArgsMap.set(toolName, callArgs);
472
- }
473
- }
474
- // Process tool results
475
- if (step?.toolResults && Array.isArray(step.toolResults)) {
476
- for (const toolResult of step.toolResults) {
477
- const trRecord = toolResult;
478
- const toolName = trRecord.toolName || "unknown";
479
- const toolId = trRecord.toolCallId || trRecord.id;
480
- const toolArgs = trRecord.args ??
481
- trRecord.arguments ??
482
- trRecord.parameters ??
483
- trRecord.input ??
484
- toolCallArgsMap.get(toolId || toolName) ??
485
- {};
486
- toolExecutions.push({
487
- name: toolName,
488
- input: toolArgs,
489
- output: (trRecord.output ?? trRecord.result) ?? "success",
490
- });
491
- }
492
- }
493
- }
494
- }
495
- return { toolsUsed: [...new Set(toolsUsed)], toolExecutions };
496
- }
497
- /**
498
- * Format the enhanced result
499
- */
500
- formatEnhancedResult(generateResult, tools, toolsUsed, toolExecutions, options) {
501
- // Structured output check — schema alone is sufficient to activate
502
- const useStructuredOutput = !!options.schema ||
503
- options.output?.format === "json" ||
504
- options.output?.format === "structured";
505
- let content;
506
- let structuredData;
507
- let jsonRepaired = false;
508
- let jsonTruncated = false;
509
- // Strip an outer ```json fence and coerce raw model text into canonical
510
- // JSON. Object/array roots are recovered via balanced-scan + jsonrepair;
511
- // scalar JSON roots (string/number/bool) via plain JSON.parse. When
512
- // nothing JSON-shaped is recoverable, the raw text is returned unchanged,
513
- // structuredData stays unset, and a WARN makes the broken case observable.
514
- const coerceTextMode = (rawText) => {
515
- const strippedText = rawText
516
- .replace(/^```(?:json)?\s*\n?/i, "")
517
- .replace(/\n?```\s*$/i, "")
518
- .trim();
519
- const coerced = coerceJsonToSchema(strippedText, options.schema);
520
- if (coerced) {
521
- structuredData = coerced.structuredData;
522
- if (coerced.repaired) {
523
- jsonRepaired = true;
524
- }
525
- if (coerced.truncated) {
526
- jsonTruncated = true;
527
- }
528
- return coerced.content;
529
- }
530
- const scalar = recoverScalarRoot(strippedText, options.schema);
531
- switch (scalar.kind) {
532
- case "empty":
533
- // A JSON-encoded empty string is an EMPTY completion, not a
534
- // recovered scalar — normalize to a true empty ('' content, no
535
- // structuredData) so callers' empty-response handling fires
536
- // instead of a literal '""' reaching the user.
537
- logger.warn("[GenerationHandler] schema requested but the model returned an empty JSON string; normalizing to empty content", { provider: this.providerName, model: this.modelName });
538
- return "";
539
- case "accepted":
540
- // A JSON scalar root is only real structured data when the caller's
541
- // schema actually accepts it. Under an OBJECT schema a recovered
542
- // string/number is the raw completion in disguise (the shape a
543
- // truncated response degrades to) — publishing it would hand the
544
- // caller a `structuredData` that violates the schema they passed.
545
- structuredData = scalar.value;
546
- return strippedText;
547
- case "rejected":
548
- logger.warn("[GenerationHandler] recovered a JSON scalar the requested schema rejects; leaving structuredData unset", {
549
- provider: this.providerName,
550
- model: this.modelName,
551
- scalarType: typeof scalar.value,
552
- });
553
- return strippedText;
554
- case "nullish":
555
- case "not-json":
556
- logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
557
- return strippedText;
558
- }
559
- };
560
- if (useStructuredOutput) {
561
- try {
562
- const experimentalOutput = generateResult.experimental_output;
563
- // ai@6 generateText resolves `output ?? text()` internally, so a
564
- // result produced WITHOUT an output spec — the structured-output
565
- // fallback retry, or the tools↔schema exclusion path — no longer
566
- // throws here: `experimental_output` echoes the RAW MODEL TEXT.
567
- // Treating that echo as parsed schema output double-encodes the
568
- // content (JSON.stringify of a string) and, for an empty
569
- // completion, turns '' into the literal '""'. Detect the echo
570
- // (a string identical to the step text) and coerce it instead.
571
- const rawTextEcho = typeof experimentalOutput === "string" &&
572
- experimentalOutput === (generateResult.text ?? "");
573
- // The equality check above only catches an EXACT echo. On a multi-step
574
- // or truncated turn the echo can differ from `text` (a different step's
575
- // text, a fence, trailing whitespace), and a raw string would then be
576
- // published as `structuredData` under an object schema — the "returned
577
- // a string instead of the schema object" failure. A string is trusted
578
- // as structured output ONLY when the caller's schema accepts it
579
- // (string-root schemas keep working); otherwise it is coerced like any
580
- // other raw model text.
581
- const untrustedStringOutput = typeof experimentalOutput === "string" &&
582
- !!options.schema &&
583
- !schemaAccepts(options.schema, experimentalOutput);
584
- if (experimentalOutput !== undefined &&
585
- !rawTextEcho &&
586
- !untrustedStringOutput) {
587
- // AI-SDK already parsed + schema-validated the object. Expose it
588
- // directly and serialise canonically — no hand-parsing needed.
589
- structuredData = experimentalOutput;
590
- content = JSON.stringify(experimentalOutput);
591
- }
592
- else {
593
- content = coerceTextMode(generateResult.text || "");
594
- }
595
- }
596
- catch (outputError) {
597
- // experimental_output is a getter that can throw NoObjectGeneratedError.
598
- logger.debug("[GenerationHandler] experimental_output threw, falling back to text parsing", {
599
- error: outputError instanceof Error
600
- ? outputError.message
601
- : String(outputError),
602
- });
603
- content = coerceTextMode(generateResult.text || "");
604
- }
605
- }
606
- else {
607
- content = generateResult.text;
608
- }
609
- // Tie the coercion repair to the provider's truncation signal: if the
610
- // response stopped on the token cap, treat the structured output as
611
- // truncated regardless of which coerce candidate won, and warn.
612
- if (useStructuredOutput && generateResult.finishReason === "length") {
613
- jsonTruncated = true;
614
- logger.warn("[GenerationHandler] Structured output truncated by token cap (finishReason=length); increase maxTokens", { provider: this.providerName, model: this.modelName });
615
- }
616
- // Extract usage with support for different formats and reasoning tokens.
617
- // totalUsage is the CROSS-STEP aggregate; generateResult.usage is the
618
- // LAST step only, which silently dropped every prior step of a
619
- // multi-step tool loop.
620
- const usage = extractTokenUsage(generateResult.totalUsage ?? generateResult.usage);
621
- // Merge cache metrics from providerMetadata if not already present in usage
622
- // The AI SDK's LanguageModelUsage doesn't include cache tokens; they come from
623
- // provider-specific metadata (e.g. Anthropic's providerMetadata.anthropic)
624
- if (usage.cacheCreationTokens === undefined ||
625
- usage.cacheReadTokens === undefined) {
626
- const cacheMetrics = this.extractCacheMetricsFromProviderMetadata(generateResult);
627
- if (usage.cacheCreationTokens === undefined &&
628
- cacheMetrics.cacheCreationTokens !== undefined) {
629
- usage.cacheCreationTokens = cacheMetrics.cacheCreationTokens;
630
- }
631
- if (usage.cacheReadTokens === undefined &&
632
- cacheMetrics.cacheReadTokens !== undefined) {
633
- usage.cacheReadTokens = cacheMetrics.cacheReadTokens;
634
- }
635
- // Recalculate cache savings if we added cache metrics
636
- if (usage.cacheReadTokens !== undefined) {
637
- const savingsPercent = calculateCacheSavingsPercent(usage.cacheReadTokens, usage.input);
638
- if (savingsPercent !== undefined) {
639
- usage.cacheSavingsPercent = savingsPercent;
640
- }
641
- }
642
- }
643
- // Extract reasoning from AI SDK response (Anthropic thinking, Gemini thought, OpenAI o1)
644
- // Handle both string and array (AI SDK v5 returns string, v6 returns ReasoningOutput[])
645
- const rawReasoning = generateResult.reasoning;
646
- const reasoning = rawReasoning
647
- ? typeof rawReasoning === "string"
648
- ? rawReasoning
649
- : Array.isArray(rawReasoning)
650
- ? rawReasoning
651
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
652
- .map((r) => typeof r === "string" ? r : (r.text ?? JSON.stringify(r)))
653
- .join("\n")
654
- : String(rawReasoning)
655
- : undefined;
656
- const reasoningTokens = usage.reasoning ?? undefined;
657
- // stopReason / stepsUsed parity with the native loops (Vertex Gemini /
658
- // Claude / Bedrock): the AI-SDK loop path previously left both undefined,
659
- // so consumers could not distinguish a completed turn from one truncated
660
- // by the step cap or ended by the turn budget.
661
- const steps = generateResult.steps;
662
- const stepsUsed = Array.isArray(steps) ? steps.length : undefined;
663
- const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
664
- let stopReason;
665
- if (generateResult.__nlTurnWrapup === true) {
666
- stopReason = "time-limit";
667
- }
668
- else if (stepsUsed !== undefined &&
669
- stepsUsed >= maxSteps &&
670
- generateResult.finishReason === "tool-calls") {
671
- stopReason = "step-cap";
672
- }
673
- else if (generateResult.finishReason === "error") {
674
- // Parity with resolveTurnStopReason (native loops): a turn that ended
675
- // on a provider "error" finish is not a completion. length /
676
- // content-filter DO map to "completed" — deliberately matching the
677
- // native contract, where truncation is signaled via finishReason /
678
- // rawFinishReason / jsonTruncated, never via stopReason.
679
- stopReason = "provider-error";
680
- }
681
- else if (stepsUsed !== undefined) {
682
- stopReason = "completed";
683
- }
684
- return {
685
- content,
686
- structuredData,
687
- usage,
688
- finishReason: generateResult.finishReason,
689
- stopReason,
690
- stepsUsed,
691
- jsonRepaired: jsonRepaired || undefined,
692
- jsonTruncated: jsonTruncated || undefined,
693
- provider: this.providerName,
694
- model: this.modelName,
695
- reasoning,
696
- reasoningTokens,
697
- toolCalls: generateResult.toolCalls
698
- ? generateResult.toolCalls.map((tc) => ({
699
- toolCallId: tc.toolCallId || "unknown",
700
- toolName: tc.toolName || "unknown",
701
- args: tc.args || {},
702
- }))
703
- : [],
704
- toolResults: generateResult.toolResults ?? [],
705
- toolsUsed,
706
- toolExecutions,
707
- availableTools: Object.keys(tools).map((name) => {
708
- const tool = tools[name];
709
- return {
710
- name,
711
- description: tool.description || "No description available",
712
- parameters: tool.inputSchema || {},
713
- server: tool.serverId || "direct",
714
- };
715
- }),
716
- };
717
- }
718
- /**
719
- * Analyze AI response structure and log detailed debugging information
720
- */
721
- analyzeAIResponse(rawResult) {
722
- if (rawResult === null || typeof rawResult !== "object") {
723
- return;
724
- }
725
- const result = rawResult;
726
- logger.debug("NeuroLink Raw AI Response Analysis", {
727
- provider: this.providerName,
728
- model: this.modelName,
729
- responseTextLength: result.text?.length || 0,
730
- responsePreview: result.text?.substring(0, 500) ?? "",
731
- finishReason: result.finishReason,
732
- usage: result.usage,
733
- });
734
- // Tool calls analysis
735
- const toolCallsAnalysis = {
736
- hasToolCalls: !!result.toolCalls,
737
- toolCallsLength: result.toolCalls?.length || 0,
738
- toolCalls: result.toolCalls?.map((toolCall, index) => {
739
- const tcRecord = toolCall;
740
- const toolName = tcRecord.toolName || tcRecord.name || "unknown";
741
- return {
742
- index: index + 1,
743
- toolName,
744
- toolId: tcRecord.toolCallId || tcRecord.id || "none",
745
- hasArgs: !!tcRecord.args,
746
- argsKeys: tcRecord.args && typeof tcRecord.args === "object"
747
- ? Object.keys(tcRecord.args)
748
- : [],
749
- };
750
- }) || [],
751
- };
752
- logger.debug("Tool Calls Analysis", toolCallsAnalysis);
753
- }
754
- }