@juspay/neurolink 9.70.0 → 9.70.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +355 -347
- package/dist/core/modules/GenerationHandler.js +75 -23
- package/dist/core/modules/structuredOutputPolicy.d.ts +28 -0
- package/dist/core/modules/structuredOutputPolicy.js +50 -0
- package/dist/lib/core/modules/GenerationHandler.js +75 -23
- package/dist/lib/core/modules/structuredOutputPolicy.d.ts +28 -0
- package/dist/lib/core/modules/structuredOutputPolicy.js +51 -0
- package/dist/lib/neurolink.js +58 -0
- package/dist/lib/providers/anthropic.js +34 -7
- package/dist/lib/providers/googleVertex.js +32 -9
- package/dist/lib/types/generate.d.ts +47 -19
- package/dist/lib/types/utilities.d.ts +16 -0
- package/dist/lib/utils/json/coerce.d.ts +10 -0
- package/dist/lib/utils/json/coerce.js +141 -0
- package/dist/lib/utils/json/extract.d.ts +10 -0
- package/dist/lib/utils/json/extract.js +61 -11
- package/dist/lib/utils/modelDetection.d.ts +17 -0
- package/dist/lib/utils/modelDetection.js +23 -0
- package/dist/lib/utils/tokenLimits.d.ts +20 -0
- package/dist/lib/utils/tokenLimits.js +55 -0
- package/dist/neurolink.js +58 -0
- package/dist/providers/anthropic.js +34 -7
- package/dist/providers/googleVertex.js +32 -9
- package/dist/types/generate.d.ts +47 -19
- package/dist/types/utilities.d.ts +16 -0
- package/dist/utils/json/coerce.d.ts +10 -0
- package/dist/utils/json/coerce.js +140 -0
- package/dist/utils/json/extract.d.ts +10 -0
- package/dist/utils/json/extract.js +61 -11
- package/dist/utils/modelDetection.d.ts +17 -0
- package/dist/utils/modelDetection.js +23 -0
- package/dist/utils/tokenLimits.d.ts +20 -0
- package/dist/utils/tokenLimits.js +55 -0
- package/package.json +4 -1
|
@@ -21,6 +21,8 @@ import { calculateCost } from "../../utils/pricing.js";
|
|
|
21
21
|
import { withProviderRetry } from "../../utils/providerRetry.js";
|
|
22
22
|
import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
|
|
23
23
|
import { DEFAULT_MAX_STEPS } from "../constants.js";
|
|
24
|
+
import { isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
|
|
25
|
+
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
|
|
24
26
|
import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
|
|
25
27
|
import { Output, stepCountIs } from "../../utils/tool.js";
|
|
26
28
|
import { generateText } from "../../utils/generation.js";
|
|
@@ -76,8 +78,10 @@ export class GenerationHandler {
|
|
|
76
78
|
(!!options.schema ||
|
|
77
79
|
options.output?.format === "json" ||
|
|
78
80
|
options.output?.format === "structured");
|
|
81
|
+
// The tools↔schema conflict is a Gemini-only API limitation. Vertex+Claude
|
|
82
|
+
// supports both simultaneously, so only exclude for actual Gemini models.
|
|
79
83
|
const useStructuredOutput = wantsStructuredOutput &&
|
|
80
|
-
!(
|
|
84
|
+
!isToolsSchemaExclusionInForce(this.providerName, this.modelName, shouldUseTools, Object.keys(tools).length);
|
|
81
85
|
// Annotate the last tool with cache_control so the full tool-definition
|
|
82
86
|
// block becomes a cache breakpoint for Anthropic-family providers.
|
|
83
87
|
// Non-Anthropic providers harmlessly ignore unknown providerOptions.
|
|
@@ -279,20 +283,30 @@ export class GenerationHandler {
|
|
|
279
283
|
return result;
|
|
280
284
|
}
|
|
281
285
|
catch (error) {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
286
|
+
// Fall back to text-mode (no experimental_output) when structured
|
|
287
|
+
// output + tools failed, in two cases:
|
|
288
|
+
// 1. NoObjectGeneratedError — the SDK couldn't coerce the object.
|
|
289
|
+
// 2. The provider rejected json-mode-with-tools outright (e.g. Groq:
|
|
290
|
+
// "json mode cannot be combined with tool/function calling").
|
|
291
|
+
// In both cases we retry without structured output and let
|
|
292
|
+
// formatEnhancedResult coerce the text response into valid JSON.
|
|
293
|
+
const isStructuredOutputConflict = useStructuredOutput &&
|
|
294
|
+
(error instanceof NoObjectGeneratedError ||
|
|
295
|
+
isToolsSchemaConflictError(error));
|
|
296
|
+
if (isStructuredOutputConflict) {
|
|
285
297
|
span.setAttribute("neurolink.has_fallback", true);
|
|
286
298
|
// NLK-GAP-007: Record initial failure event before fallback retry
|
|
287
299
|
span.addEvent("retry.initial_failure", {
|
|
288
|
-
"error.message": error.message,
|
|
300
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
289
301
|
"retry.attempt": 1,
|
|
290
|
-
"retry.reason":
|
|
302
|
+
"retry.reason": error instanceof NoObjectGeneratedError
|
|
303
|
+
? "NoObjectGeneratedError_structured_output_fallback"
|
|
304
|
+
: "tools_schema_conflict_structured_output_fallback",
|
|
291
305
|
});
|
|
292
|
-
logger.debug("[GenerationHandler]
|
|
306
|
+
logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
|
|
293
307
|
provider: this.providerName,
|
|
294
308
|
model: this.modelName,
|
|
295
|
-
error: error.message,
|
|
309
|
+
error: error instanceof Error ? error.message : String(error),
|
|
296
310
|
});
|
|
297
311
|
// Retry without experimental_output - the formatEnhancedResult method
|
|
298
312
|
// will extract JSON from the text response
|
|
@@ -460,41 +474,76 @@ export class GenerationHandler {
|
|
|
460
474
|
options.output?.format === "json" ||
|
|
461
475
|
options.output?.format === "structured";
|
|
462
476
|
let content;
|
|
477
|
+
let structuredData;
|
|
478
|
+
let jsonRepaired = false;
|
|
479
|
+
let jsonTruncated = false;
|
|
480
|
+
// Strip an outer ```json fence and coerce raw model text into canonical
|
|
481
|
+
// JSON. Object/array roots are recovered via balanced-scan + jsonrepair;
|
|
482
|
+
// scalar JSON roots (string/number/bool) via plain JSON.parse. When
|
|
483
|
+
// nothing JSON-shaped is recoverable, the raw text is returned unchanged,
|
|
484
|
+
// structuredData stays unset, and a WARN makes the broken case observable.
|
|
485
|
+
const coerceTextMode = (rawText) => {
|
|
486
|
+
const strippedText = rawText
|
|
487
|
+
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
488
|
+
.replace(/\n?```\s*$/i, "")
|
|
489
|
+
.trim();
|
|
490
|
+
const coerced = coerceJsonToSchema(strippedText, options.schema);
|
|
491
|
+
if (coerced) {
|
|
492
|
+
structuredData = coerced.structuredData;
|
|
493
|
+
if (coerced.repaired) {
|
|
494
|
+
jsonRepaired = true;
|
|
495
|
+
}
|
|
496
|
+
if (coerced.truncated) {
|
|
497
|
+
jsonTruncated = true;
|
|
498
|
+
}
|
|
499
|
+
return coerced.content;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
const scalar = JSON.parse(strippedText);
|
|
503
|
+
if (scalar !== null && scalar !== undefined) {
|
|
504
|
+
structuredData = scalar;
|
|
505
|
+
return strippedText;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// not JSON at all — fall through to raw text + WARN
|
|
510
|
+
}
|
|
511
|
+
logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
|
|
512
|
+
return strippedText;
|
|
513
|
+
};
|
|
463
514
|
if (useStructuredOutput) {
|
|
464
515
|
try {
|
|
465
516
|
const experimentalOutput = generateResult.experimental_output;
|
|
466
517
|
if (experimentalOutput !== undefined) {
|
|
518
|
+
// AI-SDK already parsed + schema-validated the object. Expose it
|
|
519
|
+
// directly and serialise canonically — no hand-parsing needed.
|
|
520
|
+
structuredData = experimentalOutput;
|
|
467
521
|
content = JSON.stringify(experimentalOutput);
|
|
468
522
|
}
|
|
469
523
|
else {
|
|
470
|
-
|
|
471
|
-
const rawText = generateResult.text || "";
|
|
472
|
-
const strippedText = rawText
|
|
473
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
474
|
-
.replace(/\n?```\s*$/i, "")
|
|
475
|
-
.trim();
|
|
476
|
-
content = strippedText;
|
|
524
|
+
content = coerceTextMode(generateResult.text || "");
|
|
477
525
|
}
|
|
478
526
|
}
|
|
479
527
|
catch (outputError) {
|
|
480
|
-
// experimental_output is a getter that can throw NoObjectGeneratedError
|
|
481
|
-
// Fall back to text parsing when structured output fails
|
|
528
|
+
// experimental_output is a getter that can throw NoObjectGeneratedError.
|
|
482
529
|
logger.debug("[GenerationHandler] experimental_output threw, falling back to text parsing", {
|
|
483
530
|
error: outputError instanceof Error
|
|
484
531
|
? outputError.message
|
|
485
532
|
: String(outputError),
|
|
486
533
|
});
|
|
487
|
-
|
|
488
|
-
const strippedText = rawText
|
|
489
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
490
|
-
.replace(/\n?```\s*$/i, "")
|
|
491
|
-
.trim();
|
|
492
|
-
content = strippedText;
|
|
534
|
+
content = coerceTextMode(generateResult.text || "");
|
|
493
535
|
}
|
|
494
536
|
}
|
|
495
537
|
else {
|
|
496
538
|
content = generateResult.text;
|
|
497
539
|
}
|
|
540
|
+
// Tie the coercion repair to the provider's truncation signal: if the
|
|
541
|
+
// response stopped on the token cap, treat the structured output as
|
|
542
|
+
// truncated regardless of which coerce candidate won, and warn.
|
|
543
|
+
if (useStructuredOutput && generateResult.finishReason === "length") {
|
|
544
|
+
jsonTruncated = true;
|
|
545
|
+
logger.warn("[GenerationHandler] Structured output truncated by token cap (finishReason=length); increase maxTokens", { provider: this.providerName, model: this.modelName });
|
|
546
|
+
}
|
|
498
547
|
// Extract usage with support for different formats and reasoning tokens
|
|
499
548
|
// Note: The AI SDK bundles thinking tokens into promptTokens for Google models.
|
|
500
549
|
// Separate reasoningTokens tracking will work when/if the AI SDK adds support.
|
|
@@ -537,8 +586,11 @@ export class GenerationHandler {
|
|
|
537
586
|
const reasoningTokens = usage.reasoning ?? undefined;
|
|
538
587
|
return {
|
|
539
588
|
content,
|
|
589
|
+
structuredData,
|
|
540
590
|
usage,
|
|
541
591
|
finishReason: generateResult.finishReason,
|
|
592
|
+
jsonRepaired: jsonRepaired || undefined,
|
|
593
|
+
jsonTruncated: jsonTruncated || undefined,
|
|
542
594
|
provider: this.providerName,
|
|
543
595
|
model: this.modelName,
|
|
544
596
|
reasoning,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy for when AI-SDK structured output (experimental_output) must be
|
|
3
|
+
* disabled because the provider cannot combine tool calls with JSON-schema
|
|
4
|
+
* enforcement.
|
|
5
|
+
*
|
|
6
|
+
* This is a GEMINI-ONLY API limitation. Anthropic Claude — including when
|
|
7
|
+
* hosted on Vertex (modelName starts with "claude-") — supports tools and
|
|
8
|
+
* structured output simultaneously, so it must NOT be excluded. A gate keyed on
|
|
9
|
+
* "any Vertex model" wrongly disables structured output for Vertex+Claude (the
|
|
10
|
+
* primary production config) and forces fragile hand-parsed JSON.
|
|
11
|
+
*/
|
|
12
|
+
/** True when the provider+model is a Gemini model (the only family with the tools↔schema conflict). */
|
|
13
|
+
export declare function isGeminiProvider(providerName: string, modelName: string | undefined): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* True when structured output must be disabled for this call because tools are
|
|
16
|
+
* active on a Gemini provider. Mirrors the AI-SDK constraint exactly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isToolsSchemaExclusionInForce(providerName: string, modelName: string | undefined, shouldUseTools: boolean, toolCount: number): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when a provider error indicates the request was rejected because JSON /
|
|
21
|
+
* structured output and tool-calling cannot be combined for that provider
|
|
22
|
+
* (e.g. Groq: "json mode cannot be combined with tool/function calling").
|
|
23
|
+
*
|
|
24
|
+
* This is the runtime, provider-agnostic complement to the static Gemini gate:
|
|
25
|
+
* any provider with the same limitation can be detected from its error message
|
|
26
|
+
* and retried without structured output instead of failing the call.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isToolsSchemaConflictError(error: unknown): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy for when AI-SDK structured output (experimental_output) must be
|
|
3
|
+
* disabled because the provider cannot combine tool calls with JSON-schema
|
|
4
|
+
* enforcement.
|
|
5
|
+
*
|
|
6
|
+
* This is a GEMINI-ONLY API limitation. Anthropic Claude — including when
|
|
7
|
+
* hosted on Vertex (modelName starts with "claude-") — supports tools and
|
|
8
|
+
* structured output simultaneously, so it must NOT be excluded. A gate keyed on
|
|
9
|
+
* "any Vertex model" wrongly disables structured output for Vertex+Claude (the
|
|
10
|
+
* primary production config) and forces fragile hand-parsed JSON.
|
|
11
|
+
*/
|
|
12
|
+
/** True when the provider+model is a Gemini model (the only family with the tools↔schema conflict). */
|
|
13
|
+
export function isGeminiProvider(providerName, modelName) {
|
|
14
|
+
if (providerName === "google-ai") {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (providerName === "vertex") {
|
|
18
|
+
// Vertex hosts both Gemini and Claude. Only non-Claude (Gemini) models
|
|
19
|
+
// have the tools↔schema conflict.
|
|
20
|
+
return !(modelName?.startsWith("claude-") ?? false);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* True when structured output must be disabled for this call because tools are
|
|
26
|
+
* active on a Gemini provider. Mirrors the AI-SDK constraint exactly.
|
|
27
|
+
*/
|
|
28
|
+
export function isToolsSchemaExclusionInForce(providerName, modelName, shouldUseTools, toolCount) {
|
|
29
|
+
return (isGeminiProvider(providerName, modelName) && shouldUseTools && toolCount > 0);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* True when a provider error indicates the request was rejected because JSON /
|
|
33
|
+
* structured output and tool-calling cannot be combined for that provider
|
|
34
|
+
* (e.g. Groq: "json mode cannot be combined with tool/function calling").
|
|
35
|
+
*
|
|
36
|
+
* This is the runtime, provider-agnostic complement to the static Gemini gate:
|
|
37
|
+
* any provider with the same limitation can be detected from its error message
|
|
38
|
+
* and retried without structured output instead of failing the call.
|
|
39
|
+
*/
|
|
40
|
+
export function isToolsSchemaConflictError(error) {
|
|
41
|
+
const message = error instanceof Error
|
|
42
|
+
? error.message
|
|
43
|
+
: typeof error === "string"
|
|
44
|
+
? error
|
|
45
|
+
: "";
|
|
46
|
+
return (/cannot be combined with (a )?(tool|function)/i.test(message) ||
|
|
47
|
+
/json[\s_-]?(mode|schema|object|output)[^.]{0,60}(tool|function)/i.test(message) ||
|
|
48
|
+
/(tool|function)[\s-]?call[^.]{0,60}json[\s_-]?(mode|schema)/i.test(message) ||
|
|
49
|
+
/response_format[^.]{0,60}(tool|function)/i.test(message));
|
|
50
|
+
}
|
|
@@ -21,6 +21,8 @@ import { calculateCost } from "../../utils/pricing.js";
|
|
|
21
21
|
import { withProviderRetry } from "../../utils/providerRetry.js";
|
|
22
22
|
import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
|
|
23
23
|
import { DEFAULT_MAX_STEPS } from "../constants.js";
|
|
24
|
+
import { isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
|
|
25
|
+
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
|
|
24
26
|
import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
|
|
25
27
|
import { Output, stepCountIs } from "../../utils/tool.js";
|
|
26
28
|
import { generateText } from "../../utils/generation.js";
|
|
@@ -76,8 +78,10 @@ export class GenerationHandler {
|
|
|
76
78
|
(!!options.schema ||
|
|
77
79
|
options.output?.format === "json" ||
|
|
78
80
|
options.output?.format === "structured");
|
|
81
|
+
// The tools↔schema conflict is a Gemini-only API limitation. Vertex+Claude
|
|
82
|
+
// supports both simultaneously, so only exclude for actual Gemini models.
|
|
79
83
|
const useStructuredOutput = wantsStructuredOutput &&
|
|
80
|
-
!(
|
|
84
|
+
!isToolsSchemaExclusionInForce(this.providerName, this.modelName, shouldUseTools, Object.keys(tools).length);
|
|
81
85
|
// Annotate the last tool with cache_control so the full tool-definition
|
|
82
86
|
// block becomes a cache breakpoint for Anthropic-family providers.
|
|
83
87
|
// Non-Anthropic providers harmlessly ignore unknown providerOptions.
|
|
@@ -279,20 +283,30 @@ export class GenerationHandler {
|
|
|
279
283
|
return result;
|
|
280
284
|
}
|
|
281
285
|
catch (error) {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
286
|
+
// Fall back to text-mode (no experimental_output) when structured
|
|
287
|
+
// output + tools failed, in two cases:
|
|
288
|
+
// 1. NoObjectGeneratedError — the SDK couldn't coerce the object.
|
|
289
|
+
// 2. The provider rejected json-mode-with-tools outright (e.g. Groq:
|
|
290
|
+
// "json mode cannot be combined with tool/function calling").
|
|
291
|
+
// In both cases we retry without structured output and let
|
|
292
|
+
// formatEnhancedResult coerce the text response into valid JSON.
|
|
293
|
+
const isStructuredOutputConflict = useStructuredOutput &&
|
|
294
|
+
(error instanceof NoObjectGeneratedError ||
|
|
295
|
+
isToolsSchemaConflictError(error));
|
|
296
|
+
if (isStructuredOutputConflict) {
|
|
285
297
|
span.setAttribute("neurolink.has_fallback", true);
|
|
286
298
|
// NLK-GAP-007: Record initial failure event before fallback retry
|
|
287
299
|
span.addEvent("retry.initial_failure", {
|
|
288
|
-
"error.message": error.message,
|
|
300
|
+
"error.message": error instanceof Error ? error.message : String(error),
|
|
289
301
|
"retry.attempt": 1,
|
|
290
|
-
"retry.reason":
|
|
302
|
+
"retry.reason": error instanceof NoObjectGeneratedError
|
|
303
|
+
? "NoObjectGeneratedError_structured_output_fallback"
|
|
304
|
+
: "tools_schema_conflict_structured_output_fallback",
|
|
291
305
|
});
|
|
292
|
-
logger.debug("[GenerationHandler]
|
|
306
|
+
logger.debug("[GenerationHandler] structured-output conflict caught - falling back to manual JSON extraction", {
|
|
293
307
|
provider: this.providerName,
|
|
294
308
|
model: this.modelName,
|
|
295
|
-
error: error.message,
|
|
309
|
+
error: error instanceof Error ? error.message : String(error),
|
|
296
310
|
});
|
|
297
311
|
// Retry without experimental_output - the formatEnhancedResult method
|
|
298
312
|
// will extract JSON from the text response
|
|
@@ -460,41 +474,76 @@ export class GenerationHandler {
|
|
|
460
474
|
options.output?.format === "json" ||
|
|
461
475
|
options.output?.format === "structured";
|
|
462
476
|
let content;
|
|
477
|
+
let structuredData;
|
|
478
|
+
let jsonRepaired = false;
|
|
479
|
+
let jsonTruncated = false;
|
|
480
|
+
// Strip an outer ```json fence and coerce raw model text into canonical
|
|
481
|
+
// JSON. Object/array roots are recovered via balanced-scan + jsonrepair;
|
|
482
|
+
// scalar JSON roots (string/number/bool) via plain JSON.parse. When
|
|
483
|
+
// nothing JSON-shaped is recoverable, the raw text is returned unchanged,
|
|
484
|
+
// structuredData stays unset, and a WARN makes the broken case observable.
|
|
485
|
+
const coerceTextMode = (rawText) => {
|
|
486
|
+
const strippedText = rawText
|
|
487
|
+
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
488
|
+
.replace(/\n?```\s*$/i, "")
|
|
489
|
+
.trim();
|
|
490
|
+
const coerced = coerceJsonToSchema(strippedText, options.schema);
|
|
491
|
+
if (coerced) {
|
|
492
|
+
structuredData = coerced.structuredData;
|
|
493
|
+
if (coerced.repaired) {
|
|
494
|
+
jsonRepaired = true;
|
|
495
|
+
}
|
|
496
|
+
if (coerced.truncated) {
|
|
497
|
+
jsonTruncated = true;
|
|
498
|
+
}
|
|
499
|
+
return coerced.content;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
const scalar = JSON.parse(strippedText);
|
|
503
|
+
if (scalar !== null && scalar !== undefined) {
|
|
504
|
+
structuredData = scalar;
|
|
505
|
+
return strippedText;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// not JSON at all — fall through to raw text + WARN
|
|
510
|
+
}
|
|
511
|
+
logger.warn("[GenerationHandler] schema requested but no JSON could be recovered from model text; returning raw text", { provider: this.providerName, model: this.modelName });
|
|
512
|
+
return strippedText;
|
|
513
|
+
};
|
|
463
514
|
if (useStructuredOutput) {
|
|
464
515
|
try {
|
|
465
516
|
const experimentalOutput = generateResult.experimental_output;
|
|
466
517
|
if (experimentalOutput !== undefined) {
|
|
518
|
+
// AI-SDK already parsed + schema-validated the object. Expose it
|
|
519
|
+
// directly and serialise canonically — no hand-parsing needed.
|
|
520
|
+
structuredData = experimentalOutput;
|
|
467
521
|
content = JSON.stringify(experimentalOutput);
|
|
468
522
|
}
|
|
469
523
|
else {
|
|
470
|
-
|
|
471
|
-
const rawText = generateResult.text || "";
|
|
472
|
-
const strippedText = rawText
|
|
473
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
474
|
-
.replace(/\n?```\s*$/i, "")
|
|
475
|
-
.trim();
|
|
476
|
-
content = strippedText;
|
|
524
|
+
content = coerceTextMode(generateResult.text || "");
|
|
477
525
|
}
|
|
478
526
|
}
|
|
479
527
|
catch (outputError) {
|
|
480
|
-
// experimental_output is a getter that can throw NoObjectGeneratedError
|
|
481
|
-
// Fall back to text parsing when structured output fails
|
|
528
|
+
// experimental_output is a getter that can throw NoObjectGeneratedError.
|
|
482
529
|
logger.debug("[GenerationHandler] experimental_output threw, falling back to text parsing", {
|
|
483
530
|
error: outputError instanceof Error
|
|
484
531
|
? outputError.message
|
|
485
532
|
: String(outputError),
|
|
486
533
|
});
|
|
487
|
-
|
|
488
|
-
const strippedText = rawText
|
|
489
|
-
.replace(/^```(?:json)?\s*\n?/i, "")
|
|
490
|
-
.replace(/\n?```\s*$/i, "")
|
|
491
|
-
.trim();
|
|
492
|
-
content = strippedText;
|
|
534
|
+
content = coerceTextMode(generateResult.text || "");
|
|
493
535
|
}
|
|
494
536
|
}
|
|
495
537
|
else {
|
|
496
538
|
content = generateResult.text;
|
|
497
539
|
}
|
|
540
|
+
// Tie the coercion repair to the provider's truncation signal: if the
|
|
541
|
+
// response stopped on the token cap, treat the structured output as
|
|
542
|
+
// truncated regardless of which coerce candidate won, and warn.
|
|
543
|
+
if (useStructuredOutput && generateResult.finishReason === "length") {
|
|
544
|
+
jsonTruncated = true;
|
|
545
|
+
logger.warn("[GenerationHandler] Structured output truncated by token cap (finishReason=length); increase maxTokens", { provider: this.providerName, model: this.modelName });
|
|
546
|
+
}
|
|
498
547
|
// Extract usage with support for different formats and reasoning tokens
|
|
499
548
|
// Note: The AI SDK bundles thinking tokens into promptTokens for Google models.
|
|
500
549
|
// Separate reasoningTokens tracking will work when/if the AI SDK adds support.
|
|
@@ -537,8 +586,11 @@ export class GenerationHandler {
|
|
|
537
586
|
const reasoningTokens = usage.reasoning ?? undefined;
|
|
538
587
|
return {
|
|
539
588
|
content,
|
|
589
|
+
structuredData,
|
|
540
590
|
usage,
|
|
541
591
|
finishReason: generateResult.finishReason,
|
|
592
|
+
jsonRepaired: jsonRepaired || undefined,
|
|
593
|
+
jsonTruncated: jsonTruncated || undefined,
|
|
542
594
|
provider: this.providerName,
|
|
543
595
|
model: this.modelName,
|
|
544
596
|
reasoning,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy for when AI-SDK structured output (experimental_output) must be
|
|
3
|
+
* disabled because the provider cannot combine tool calls with JSON-schema
|
|
4
|
+
* enforcement.
|
|
5
|
+
*
|
|
6
|
+
* This is a GEMINI-ONLY API limitation. Anthropic Claude — including when
|
|
7
|
+
* hosted on Vertex (modelName starts with "claude-") — supports tools and
|
|
8
|
+
* structured output simultaneously, so it must NOT be excluded. A gate keyed on
|
|
9
|
+
* "any Vertex model" wrongly disables structured output for Vertex+Claude (the
|
|
10
|
+
* primary production config) and forces fragile hand-parsed JSON.
|
|
11
|
+
*/
|
|
12
|
+
/** True when the provider+model is a Gemini model (the only family with the tools↔schema conflict). */
|
|
13
|
+
export declare function isGeminiProvider(providerName: string, modelName: string | undefined): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* True when structured output must be disabled for this call because tools are
|
|
16
|
+
* active on a Gemini provider. Mirrors the AI-SDK constraint exactly.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isToolsSchemaExclusionInForce(providerName: string, modelName: string | undefined, shouldUseTools: boolean, toolCount: number): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* True when a provider error indicates the request was rejected because JSON /
|
|
21
|
+
* structured output and tool-calling cannot be combined for that provider
|
|
22
|
+
* (e.g. Groq: "json mode cannot be combined with tool/function calling").
|
|
23
|
+
*
|
|
24
|
+
* This is the runtime, provider-agnostic complement to the static Gemini gate:
|
|
25
|
+
* any provider with the same limitation can be detected from its error message
|
|
26
|
+
* and retried without structured output instead of failing the call.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isToolsSchemaConflictError(error: unknown): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy for when AI-SDK structured output (experimental_output) must be
|
|
3
|
+
* disabled because the provider cannot combine tool calls with JSON-schema
|
|
4
|
+
* enforcement.
|
|
5
|
+
*
|
|
6
|
+
* This is a GEMINI-ONLY API limitation. Anthropic Claude — including when
|
|
7
|
+
* hosted on Vertex (modelName starts with "claude-") — supports tools and
|
|
8
|
+
* structured output simultaneously, so it must NOT be excluded. A gate keyed on
|
|
9
|
+
* "any Vertex model" wrongly disables structured output for Vertex+Claude (the
|
|
10
|
+
* primary production config) and forces fragile hand-parsed JSON.
|
|
11
|
+
*/
|
|
12
|
+
/** True when the provider+model is a Gemini model (the only family with the tools↔schema conflict). */
|
|
13
|
+
export function isGeminiProvider(providerName, modelName) {
|
|
14
|
+
if (providerName === "google-ai") {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (providerName === "vertex") {
|
|
18
|
+
// Vertex hosts both Gemini and Claude. Only non-Claude (Gemini) models
|
|
19
|
+
// have the tools↔schema conflict.
|
|
20
|
+
return !(modelName?.startsWith("claude-") ?? false);
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* True when structured output must be disabled for this call because tools are
|
|
26
|
+
* active on a Gemini provider. Mirrors the AI-SDK constraint exactly.
|
|
27
|
+
*/
|
|
28
|
+
export function isToolsSchemaExclusionInForce(providerName, modelName, shouldUseTools, toolCount) {
|
|
29
|
+
return (isGeminiProvider(providerName, modelName) && shouldUseTools && toolCount > 0);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* True when a provider error indicates the request was rejected because JSON /
|
|
33
|
+
* structured output and tool-calling cannot be combined for that provider
|
|
34
|
+
* (e.g. Groq: "json mode cannot be combined with tool/function calling").
|
|
35
|
+
*
|
|
36
|
+
* This is the runtime, provider-agnostic complement to the static Gemini gate:
|
|
37
|
+
* any provider with the same limitation can be detected from its error message
|
|
38
|
+
* and retried without structured output instead of failing the call.
|
|
39
|
+
*/
|
|
40
|
+
export function isToolsSchemaConflictError(error) {
|
|
41
|
+
const message = error instanceof Error
|
|
42
|
+
? error.message
|
|
43
|
+
: typeof error === "string"
|
|
44
|
+
? error
|
|
45
|
+
: "";
|
|
46
|
+
return (/cannot be combined with (a )?(tool|function)/i.test(message) ||
|
|
47
|
+
/json[\s_-]?(mode|schema|object|output)[^.]{0,60}(tool|function)/i.test(message) ||
|
|
48
|
+
/(tool|function)[\s-]?call[^.]{0,60}json[\s_-]?(mode|schema)/i.test(message) ||
|
|
49
|
+
/response_format[^.]{0,60}(tool|function)/i.test(message));
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=structuredOutputPolicy.js.map
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -66,6 +66,7 @@ import { CircuitBreaker, ERROR_CODES, ErrorFactory, isAbortError, isRetriableErr
|
|
|
66
66
|
import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "./utils/lifecycleCallbacks.js";
|
|
67
67
|
import { resolveLifecycleTimeoutMs } from "./utils/lifecycleTimeout.js";
|
|
68
68
|
import { cloneOptionsForCallIsolation } from "./utils/cloneOptions.js";
|
|
69
|
+
import { coerceJsonToSchema } from "./utils/json/coerce.js";
|
|
69
70
|
// Factory processing imports
|
|
70
71
|
import { createCleanStreamOptions, enhanceTextGenerationOptions, processFactoryOptions, processStreamingFactoryOptions, validateFactoryConfig, } from "./utils/factoryProcessing.js";
|
|
71
72
|
import { logger, mcpLogger } from "./utils/logger.js";
|
|
@@ -3345,6 +3346,60 @@ Current user's request: ${currentInput}`;
|
|
|
3345
3346
|
}
|
|
3346
3347
|
finalizeGenerateRequestResult(params) {
|
|
3347
3348
|
const { generateSpan, options, textOptions, textResult, factoryResult, originalPrompt, startTime, } = params;
|
|
3349
|
+
// Provider-agnostic JSON coercion for schema requests. Structured-output
|
|
3350
|
+
// enforcement makes valid JSON the overwhelming case; for every other
|
|
3351
|
+
// provider path — including generate() overrides (Vertex, Anthropic,
|
|
3352
|
+
// Bedrock, Google AI Studio) — object/array roots are recovered here via
|
|
3353
|
+
// balanced-scan + jsonrepair and scalar JSON roots via plain JSON.parse,
|
|
3354
|
+
// with the parsed value exposed as `structuredData`. If nothing
|
|
3355
|
+
// JSON-shaped is recoverable (pure prose), the raw text is returned,
|
|
3356
|
+
// `structuredData` stays undefined, and a WARN makes the case observable.
|
|
3357
|
+
// Runs BEFORE the end-of-generation emits below so event consumers see
|
|
3358
|
+
// the same coerced content/structuredData the caller receives.
|
|
3359
|
+
if (textOptions.schema &&
|
|
3360
|
+
textResult.structuredData === undefined &&
|
|
3361
|
+
typeof textResult.content === "string") {
|
|
3362
|
+
const coerced = coerceJsonToSchema(textResult.content, textOptions.schema);
|
|
3363
|
+
if (coerced) {
|
|
3364
|
+
textResult.content = coerced.content;
|
|
3365
|
+
textResult.structuredData = coerced.structuredData;
|
|
3366
|
+
if (coerced.repaired) {
|
|
3367
|
+
textResult.jsonRepaired = true;
|
|
3368
|
+
}
|
|
3369
|
+
if (coerced.truncated) {
|
|
3370
|
+
textResult.jsonTruncated = true;
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
else {
|
|
3374
|
+
try {
|
|
3375
|
+
const scalar = JSON.parse(textResult.content);
|
|
3376
|
+
if (scalar !== null && scalar !== undefined) {
|
|
3377
|
+
textResult.structuredData = scalar;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
catch {
|
|
3381
|
+
logger.warn("[NeuroLink] schema requested but no JSON could be recovered from model output; returning raw text", { provider: textResult.provider, model: textResult.model });
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
// Surface truncation when a schema was requested: either the provider
|
|
3386
|
+
// reported finishReason="length" or the recovered JSON came from an
|
|
3387
|
+
// unclosed span. Either way `structuredData` may be incomplete — warn at
|
|
3388
|
+
// info level so it is observable in production (not just debug logs).
|
|
3389
|
+
if (textOptions.schema) {
|
|
3390
|
+
if (textResult.finishReason === "length") {
|
|
3391
|
+
textResult.jsonTruncated = true;
|
|
3392
|
+
}
|
|
3393
|
+
if (textResult.jsonTruncated) {
|
|
3394
|
+
logger.warn("[NeuroLink] Structured output may be truncated (finishReason=length or unclosed JSON); " +
|
|
3395
|
+
"increase maxTokens to fit the full response.", {
|
|
3396
|
+
provider: textResult.provider,
|
|
3397
|
+
model: textResult.model,
|
|
3398
|
+
finishReason: textResult.finishReason,
|
|
3399
|
+
outputTokens: textResult.usage?.output,
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3348
3403
|
// Skip the top-level `generation:end` emission when the provider already
|
|
3349
3404
|
// emitted it from its native generate path (Vertex / Google AI Studio).
|
|
3350
3405
|
// Without this guard, native-path providers would surface TWO events
|
|
@@ -3378,7 +3433,10 @@ Current user's request: ${currentInput}`;
|
|
|
3378
3433
|
this.emitter.emit("message", `Generation completed in ${Date.now() - startTime}ms`);
|
|
3379
3434
|
const generateResult = {
|
|
3380
3435
|
content: textResult.content,
|
|
3436
|
+
structuredData: textResult.structuredData,
|
|
3381
3437
|
finishReason: textResult.finishReason,
|
|
3438
|
+
jsonRepaired: textResult.jsonRepaired,
|
|
3439
|
+
jsonTruncated: textResult.jsonTruncated,
|
|
3382
3440
|
provider: textResult.provider,
|
|
3383
3441
|
model: textResult.model,
|
|
3384
3442
|
usage: textResult.usage
|