@juspay/neurolink 9.25.0 → 9.25.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/core/baseProvider.js +2 -51
- package/dist/lib/core/baseProvider.js +2 -51
- package/dist/lib/neurolink.js +95 -30
- package/dist/lib/observability/exporters/langfuseExporter.js +5 -1
- package/dist/lib/observability/metricsAggregator.js +6 -2
- package/dist/lib/observability/spanProcessor.js +18 -2
- package/dist/lib/observability/utils/safeMetadata.d.ts +10 -0
- package/dist/lib/observability/utils/safeMetadata.js +26 -0
- package/dist/lib/observability/utils/spanSerializer.js +5 -1
- package/dist/lib/providers/googleVertex.js +2 -6
- package/dist/neurolink.js +95 -30
- package/dist/observability/exporters/langfuseExporter.js +5 -1
- package/dist/observability/metricsAggregator.js +6 -2
- package/dist/observability/spanProcessor.js +18 -2
- package/dist/observability/utils/safeMetadata.d.ts +10 -0
- package/dist/observability/utils/safeMetadata.js +25 -0
- package/dist/observability/utils/spanSerializer.js +5 -1
- package/dist/providers/googleVertex.js +2 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [9.25.2](https://github.com/juspay/neurolink/compare/v9.25.1...v9.25.2) (2026-03-15)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
- **(core):** remove hardcoded Ollama model and unsafe type assertions (BZ-463) ([f12e03b](https://github.com/juspay/neurolink/commit/f12e03bfef007464d27c36b44694c5c4c799c6ea))
|
|
6
|
+
|
|
7
|
+
## [9.25.1](https://github.com/juspay/neurolink/compare/v9.25.0...v9.25.1) (2026-03-15)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
- **(observability):** address code review findings from PR [#860](https://github.com/juspay/neurolink/issues/860) ([1655c7e](https://github.com/juspay/neurolink/commit/1655c7ef6991eb104834e10fa7d21516539ea16d))
|
|
12
|
+
|
|
1
13
|
## [9.25.0](https://github.com/juspay/neurolink/compare/v9.24.0...v9.25.0) (2026-03-15)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -3,13 +3,9 @@ import { generateText } from "ai";
|
|
|
3
3
|
import { directAgentTools } from "../agent/directTools.js";
|
|
4
4
|
import { IMAGE_GENERATION_MODELS } from "../core/constants.js";
|
|
5
5
|
import { MiddlewareFactory } from "../middleware/factory.js";
|
|
6
|
-
import { getMetricsAggregator } from "../observability/metricsAggregator.js";
|
|
7
|
-
import { SpanStatus, SpanType } from "../observability/types/spanTypes.js";
|
|
8
|
-
import { SpanSerializer } from "../observability/utils/spanSerializer.js";
|
|
9
6
|
import { ATTR, tracers } from "../telemetry/index.js";
|
|
10
7
|
import { isAbortError } from "../utils/errorHandling.js";
|
|
11
8
|
import { logger } from "../utils/logger.js";
|
|
12
|
-
import { calculateCost } from "../utils/pricing.js";
|
|
13
9
|
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
14
10
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
15
11
|
import { getKeyCount, getKeysAsString } from "../utils/transformationUtils.js";
|
|
@@ -85,14 +81,6 @@ export class BaseProvider {
|
|
|
85
81
|
*/
|
|
86
82
|
async stream(optionsOrPrompt, analysisSchema) {
|
|
87
83
|
let options = this.normalizeStreamOptions(optionsOrPrompt);
|
|
88
|
-
// Observability: create metrics span for provider.stream
|
|
89
|
-
const metricsSpan = SpanSerializer.createSpan(SpanType.MODEL_GENERATION, "provider.stream", {
|
|
90
|
-
"ai.provider": this.providerName || "unknown",
|
|
91
|
-
"ai.model": this.modelName || options.model || "unknown",
|
|
92
|
-
"ai.temperature": options.temperature,
|
|
93
|
-
"ai.max_tokens": options.maxTokens,
|
|
94
|
-
}, this._traceContext?.parentSpanId, this._traceContext?.traceId);
|
|
95
|
-
let metricsSpanRecorded = false;
|
|
96
84
|
// OTEL span for provider-level stream tracing
|
|
97
85
|
const otelStreamSpan = tracers.provider.startSpan("neurolink.provider.stream", {
|
|
98
86
|
kind: SpanKind.CLIENT,
|
|
@@ -185,10 +173,6 @@ export class BaseProvider {
|
|
|
185
173
|
}
|
|
186
174
|
}
|
|
187
175
|
catch (error) {
|
|
188
|
-
// Observability: record failed stream span
|
|
189
|
-
metricsSpanRecorded = true;
|
|
190
|
-
const endedStreamSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
|
|
191
|
-
getMetricsAggregator().recordSpan(endedStreamSpan);
|
|
192
176
|
otelStreamSpan.setStatus({
|
|
193
177
|
code: SpanStatusCode.ERROR,
|
|
194
178
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -197,10 +181,8 @@ export class BaseProvider {
|
|
|
197
181
|
throw error;
|
|
198
182
|
}
|
|
199
183
|
finally {
|
|
200
|
-
//
|
|
201
|
-
if (
|
|
202
|
-
const endedStreamSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.OK);
|
|
203
|
-
getMetricsAggregator().recordSpan(endedStreamSpan);
|
|
184
|
+
// End OTEL span on success (only if not already ended via error path)
|
|
185
|
+
if (otelStreamSpan.isRecording()) {
|
|
204
186
|
otelStreamSpan.setStatus({ code: SpanStatusCode.OK });
|
|
205
187
|
otelStreamSpan.end();
|
|
206
188
|
}
|
|
@@ -495,13 +477,6 @@ export class BaseProvider {
|
|
|
495
477
|
const options = this.normalizeTextOptions(optionsOrPrompt);
|
|
496
478
|
this.validateOptions(options);
|
|
497
479
|
const startTime = Date.now();
|
|
498
|
-
// Observability: create metrics span for provider.generate
|
|
499
|
-
const metricsSpan = SpanSerializer.createSpan(SpanType.MODEL_GENERATION, "provider.generate", {
|
|
500
|
-
"ai.provider": this.providerName || "unknown",
|
|
501
|
-
"ai.model": this.modelName || options.model || "unknown",
|
|
502
|
-
"ai.temperature": options.temperature,
|
|
503
|
-
"ai.max_tokens": options.maxTokens,
|
|
504
|
-
}, this._traceContext?.parentSpanId, this._traceContext?.traceId);
|
|
505
480
|
// OTEL span for provider-level generate tracing
|
|
506
481
|
// Use startActiveSpan pattern via context.with() so child spans become descendants
|
|
507
482
|
const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", {
|
|
@@ -690,33 +665,9 @@ export class BaseProvider {
|
|
|
690
665
|
});
|
|
691
666
|
}
|
|
692
667
|
}
|
|
693
|
-
// Observability: record successful generate span with token/cost data
|
|
694
|
-
let enrichedGenerateSpan = { ...metricsSpan };
|
|
695
|
-
if (enhancedResult?.usage) {
|
|
696
|
-
enrichedGenerateSpan = SpanSerializer.enrichWithTokenUsage(enrichedGenerateSpan, {
|
|
697
|
-
promptTokens: enhancedResult.usage.input || 0,
|
|
698
|
-
completionTokens: enhancedResult.usage.output || 0,
|
|
699
|
-
totalTokens: enhancedResult.usage.total || 0,
|
|
700
|
-
});
|
|
701
|
-
const cost = calculateCost(this.providerName, this.modelName, {
|
|
702
|
-
input: enhancedResult.usage.input || 0,
|
|
703
|
-
output: enhancedResult.usage.output || 0,
|
|
704
|
-
total: enhancedResult.usage.total || 0,
|
|
705
|
-
});
|
|
706
|
-
if (cost && cost > 0) {
|
|
707
|
-
enrichedGenerateSpan = SpanSerializer.enrichWithCost(enrichedGenerateSpan, {
|
|
708
|
-
totalCost: cost,
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
const endedGenerateSpan = SpanSerializer.endSpan(enrichedGenerateSpan, SpanStatus.OK);
|
|
713
|
-
getMetricsAggregator().recordSpan(endedGenerateSpan);
|
|
714
668
|
return await this.enhanceResult(enhancedResult, options, startTime);
|
|
715
669
|
}
|
|
716
670
|
catch (error) {
|
|
717
|
-
// Observability: record failed generate span
|
|
718
|
-
const endedGenerateSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
|
|
719
|
-
getMetricsAggregator().recordSpan(endedGenerateSpan);
|
|
720
671
|
otelSpan.setStatus({
|
|
721
672
|
code: SpanStatusCode.ERROR,
|
|
722
673
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -3,13 +3,9 @@ import { generateText } from "ai";
|
|
|
3
3
|
import { directAgentTools } from "../agent/directTools.js";
|
|
4
4
|
import { IMAGE_GENERATION_MODELS } from "../core/constants.js";
|
|
5
5
|
import { MiddlewareFactory } from "../middleware/factory.js";
|
|
6
|
-
import { getMetricsAggregator } from "../observability/metricsAggregator.js";
|
|
7
|
-
import { SpanStatus, SpanType } from "../observability/types/spanTypes.js";
|
|
8
|
-
import { SpanSerializer } from "../observability/utils/spanSerializer.js";
|
|
9
6
|
import { ATTR, tracers } from "../telemetry/index.js";
|
|
10
7
|
import { isAbortError } from "../utils/errorHandling.js";
|
|
11
8
|
import { logger } from "../utils/logger.js";
|
|
12
|
-
import { calculateCost } from "../utils/pricing.js";
|
|
13
9
|
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
14
10
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
15
11
|
import { getKeyCount, getKeysAsString } from "../utils/transformationUtils.js";
|
|
@@ -85,14 +81,6 @@ export class BaseProvider {
|
|
|
85
81
|
*/
|
|
86
82
|
async stream(optionsOrPrompt, analysisSchema) {
|
|
87
83
|
let options = this.normalizeStreamOptions(optionsOrPrompt);
|
|
88
|
-
// Observability: create metrics span for provider.stream
|
|
89
|
-
const metricsSpan = SpanSerializer.createSpan(SpanType.MODEL_GENERATION, "provider.stream", {
|
|
90
|
-
"ai.provider": this.providerName || "unknown",
|
|
91
|
-
"ai.model": this.modelName || options.model || "unknown",
|
|
92
|
-
"ai.temperature": options.temperature,
|
|
93
|
-
"ai.max_tokens": options.maxTokens,
|
|
94
|
-
}, this._traceContext?.parentSpanId, this._traceContext?.traceId);
|
|
95
|
-
let metricsSpanRecorded = false;
|
|
96
84
|
// OTEL span for provider-level stream tracing
|
|
97
85
|
const otelStreamSpan = tracers.provider.startSpan("neurolink.provider.stream", {
|
|
98
86
|
kind: SpanKind.CLIENT,
|
|
@@ -185,10 +173,6 @@ export class BaseProvider {
|
|
|
185
173
|
}
|
|
186
174
|
}
|
|
187
175
|
catch (error) {
|
|
188
|
-
// Observability: record failed stream span
|
|
189
|
-
metricsSpanRecorded = true;
|
|
190
|
-
const endedStreamSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
|
|
191
|
-
getMetricsAggregator().recordSpan(endedStreamSpan);
|
|
192
176
|
otelStreamSpan.setStatus({
|
|
193
177
|
code: SpanStatusCode.ERROR,
|
|
194
178
|
message: error instanceof Error ? error.message : String(error),
|
|
@@ -197,10 +181,8 @@ export class BaseProvider {
|
|
|
197
181
|
throw error;
|
|
198
182
|
}
|
|
199
183
|
finally {
|
|
200
|
-
//
|
|
201
|
-
if (
|
|
202
|
-
const endedStreamSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.OK);
|
|
203
|
-
getMetricsAggregator().recordSpan(endedStreamSpan);
|
|
184
|
+
// End OTEL span on success (only if not already ended via error path)
|
|
185
|
+
if (otelStreamSpan.isRecording()) {
|
|
204
186
|
otelStreamSpan.setStatus({ code: SpanStatusCode.OK });
|
|
205
187
|
otelStreamSpan.end();
|
|
206
188
|
}
|
|
@@ -495,13 +477,6 @@ export class BaseProvider {
|
|
|
495
477
|
const options = this.normalizeTextOptions(optionsOrPrompt);
|
|
496
478
|
this.validateOptions(options);
|
|
497
479
|
const startTime = Date.now();
|
|
498
|
-
// Observability: create metrics span for provider.generate
|
|
499
|
-
const metricsSpan = SpanSerializer.createSpan(SpanType.MODEL_GENERATION, "provider.generate", {
|
|
500
|
-
"ai.provider": this.providerName || "unknown",
|
|
501
|
-
"ai.model": this.modelName || options.model || "unknown",
|
|
502
|
-
"ai.temperature": options.temperature,
|
|
503
|
-
"ai.max_tokens": options.maxTokens,
|
|
504
|
-
}, this._traceContext?.parentSpanId, this._traceContext?.traceId);
|
|
505
480
|
// OTEL span for provider-level generate tracing
|
|
506
481
|
// Use startActiveSpan pattern via context.with() so child spans become descendants
|
|
507
482
|
const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", {
|
|
@@ -690,33 +665,9 @@ export class BaseProvider {
|
|
|
690
665
|
});
|
|
691
666
|
}
|
|
692
667
|
}
|
|
693
|
-
// Observability: record successful generate span with token/cost data
|
|
694
|
-
let enrichedGenerateSpan = { ...metricsSpan };
|
|
695
|
-
if (enhancedResult?.usage) {
|
|
696
|
-
enrichedGenerateSpan = SpanSerializer.enrichWithTokenUsage(enrichedGenerateSpan, {
|
|
697
|
-
promptTokens: enhancedResult.usage.input || 0,
|
|
698
|
-
completionTokens: enhancedResult.usage.output || 0,
|
|
699
|
-
totalTokens: enhancedResult.usage.total || 0,
|
|
700
|
-
});
|
|
701
|
-
const cost = calculateCost(this.providerName, this.modelName, {
|
|
702
|
-
input: enhancedResult.usage.input || 0,
|
|
703
|
-
output: enhancedResult.usage.output || 0,
|
|
704
|
-
total: enhancedResult.usage.total || 0,
|
|
705
|
-
});
|
|
706
|
-
if (cost && cost > 0) {
|
|
707
|
-
enrichedGenerateSpan = SpanSerializer.enrichWithCost(enrichedGenerateSpan, {
|
|
708
|
-
totalCost: cost,
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
const endedGenerateSpan = SpanSerializer.endSpan(enrichedGenerateSpan, SpanStatus.OK);
|
|
713
|
-
getMetricsAggregator().recordSpan(endedGenerateSpan);
|
|
714
668
|
return await this.enhanceResult(enhancedResult, options, startTime);
|
|
715
669
|
}
|
|
716
670
|
catch (error) {
|
|
717
|
-
// Observability: record failed generate span
|
|
718
|
-
const endedGenerateSpan = SpanSerializer.endSpan(metricsSpan, SpanStatus.ERROR, error instanceof Error ? error.message : String(error));
|
|
719
|
-
getMetricsAggregator().recordSpan(endedGenerateSpan);
|
|
720
671
|
otelSpan.setStatus({
|
|
721
672
|
code: SpanStatusCode.ERROR,
|
|
722
673
|
message: error instanceof Error ? error.message : String(error),
|
package/dist/lib/neurolink.js
CHANGED
|
@@ -1263,18 +1263,42 @@ Current user's request: ${currentInput}`;
|
|
|
1263
1263
|
}
|
|
1264
1264
|
// Filter and validate models before comparison
|
|
1265
1265
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
1266
|
-
const targetModel = route.model
|
|
1267
|
-
|
|
1268
|
-
|
|
1266
|
+
const targetModel = route.model;
|
|
1267
|
+
if (targetModel) {
|
|
1268
|
+
// Check if the specific routed model is available
|
|
1269
|
+
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
|
|
1270
|
+
if (!modelIsAvailable) {
|
|
1271
|
+
logger.debug("Orchestration provider validation failed", {
|
|
1272
|
+
taskType: classification.type,
|
|
1273
|
+
routedProvider: route.provider,
|
|
1274
|
+
routedModel: route.model,
|
|
1275
|
+
reason: `Ollama model '${targetModel}' not found`,
|
|
1276
|
+
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1277
|
+
});
|
|
1278
|
+
// Fall back to first available model instead of abandoning orchestration
|
|
1279
|
+
if (validModels.length > 0) {
|
|
1280
|
+
route.model = validModels[0].name;
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
return {}; // No models at all — preserve existing fallback behavior
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
else if (validModels.length === 0) {
|
|
1288
|
+
// No model specified and none available
|
|
1269
1289
|
logger.debug("Orchestration provider validation failed", {
|
|
1270
1290
|
taskType: classification.type,
|
|
1271
1291
|
routedProvider: route.provider,
|
|
1272
1292
|
routedModel: route.model,
|
|
1273
|
-
reason:
|
|
1293
|
+
reason: "No Ollama models available",
|
|
1274
1294
|
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1275
1295
|
});
|
|
1276
1296
|
return {}; // Return empty object to preserve existing fallback behavior
|
|
1277
1297
|
}
|
|
1298
|
+
else {
|
|
1299
|
+
// No model specified but models are available — use the first one
|
|
1300
|
+
route.model = validModels[0].name;
|
|
1301
|
+
}
|
|
1278
1302
|
}
|
|
1279
1303
|
catch (error) {
|
|
1280
1304
|
logger.debug("Orchestration provider validation failed", {
|
|
@@ -1377,18 +1401,42 @@ Current user's request: ${currentInput}`;
|
|
|
1377
1401
|
}
|
|
1378
1402
|
// Filter and validate models before comparison
|
|
1379
1403
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
1380
|
-
const targetModel = route.model
|
|
1381
|
-
|
|
1382
|
-
|
|
1404
|
+
const targetModel = route.model;
|
|
1405
|
+
if (targetModel) {
|
|
1406
|
+
// Check if the specific routed model is available
|
|
1407
|
+
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
|
|
1408
|
+
if (!modelIsAvailable) {
|
|
1409
|
+
logger.debug("Stream orchestration provider validation failed", {
|
|
1410
|
+
taskType: classification.type,
|
|
1411
|
+
routedProvider: route.provider,
|
|
1412
|
+
routedModel: route.model,
|
|
1413
|
+
reason: `Ollama model '${targetModel}' not found`,
|
|
1414
|
+
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1415
|
+
});
|
|
1416
|
+
// Fall back to first available model instead of abandoning orchestration
|
|
1417
|
+
if (validModels.length > 0) {
|
|
1418
|
+
route.model = validModels[0].name;
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
return {}; // No models at all — preserve existing fallback behavior
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else if (validModels.length === 0) {
|
|
1426
|
+
// No model specified and none available
|
|
1383
1427
|
logger.debug("Stream orchestration provider validation failed", {
|
|
1384
1428
|
taskType: classification.type,
|
|
1385
1429
|
routedProvider: route.provider,
|
|
1386
1430
|
routedModel: route.model,
|
|
1387
|
-
reason:
|
|
1431
|
+
reason: "No Ollama models available",
|
|
1388
1432
|
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1389
1433
|
});
|
|
1390
1434
|
return {}; // Return empty object to preserve existing fallback behavior
|
|
1391
1435
|
}
|
|
1436
|
+
else {
|
|
1437
|
+
// No model specified but models are available — use the first one
|
|
1438
|
+
route.model = validModels[0].name;
|
|
1439
|
+
}
|
|
1392
1440
|
}
|
|
1393
1441
|
catch (error) {
|
|
1394
1442
|
logger.debug("Stream orchestration provider validation failed", {
|
|
@@ -1693,7 +1741,11 @@ Current user's request: ${currentInput}`;
|
|
|
1693
1741
|
span.spanId = traceCtx.parentSpanId;
|
|
1694
1742
|
span.parentSpanId = undefined;
|
|
1695
1743
|
}
|
|
1696
|
-
|
|
1744
|
+
// Mark failed generations with ERROR status so metrics count them correctly
|
|
1745
|
+
const spanStatus = data.success === false || data.error
|
|
1746
|
+
? SpanStatus.ERROR
|
|
1747
|
+
: SpanStatus.OK;
|
|
1748
|
+
span = SpanSerializer.endSpan(span, spanStatus, data.error ? String(data.error) : undefined);
|
|
1697
1749
|
span.durationMs = responseTime;
|
|
1698
1750
|
if (usage) {
|
|
1699
1751
|
span = SpanSerializer.enrichWithTokenUsage(span, {
|
|
@@ -2261,19 +2313,14 @@ Current user's request: ${currentInput}`;
|
|
|
2261
2313
|
evaluation: textResult.evaluation
|
|
2262
2314
|
? {
|
|
2263
2315
|
...textResult.evaluation,
|
|
2264
|
-
isOffTopic: textResult.evaluation
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
evaluationTime: textResult.evaluation
|
|
2273
|
-
.evaluationTime ?? Date.now(),
|
|
2274
|
-
// Include evaluationDomain from original options
|
|
2275
|
-
evaluationDomain: textResult.evaluation
|
|
2276
|
-
.evaluationDomain ??
|
|
2316
|
+
isOffTopic: textResult.evaluation.isOffTopic ?? false,
|
|
2317
|
+
alertSeverity: textResult.evaluation.alertSeverity ??
|
|
2318
|
+
"none",
|
|
2319
|
+
reasoning: textResult.evaluation.reasoning ??
|
|
2320
|
+
"No evaluation provided",
|
|
2321
|
+
evaluationModel: textResult.evaluation.evaluationModel ?? "unknown",
|
|
2322
|
+
evaluationTime: textResult.evaluation.evaluationTime ?? Date.now(),
|
|
2323
|
+
evaluationDomain: textResult.evaluation.evaluationDomain ??
|
|
2277
2324
|
textOptions.evaluationDomain ??
|
|
2278
2325
|
factoryResult.domainType,
|
|
2279
2326
|
}
|
|
@@ -2304,6 +2351,27 @@ Current user's request: ${currentInput}`;
|
|
|
2304
2351
|
code: SpanStatusCode.ERROR,
|
|
2305
2352
|
message: error instanceof Error ? error.message : String(error),
|
|
2306
2353
|
});
|
|
2354
|
+
// Emit generation:end on error so metrics listeners still record the failure.
|
|
2355
|
+
// Note: variables declared inside try blocks are not accessible in error
|
|
2356
|
+
// handlers, so we extract what we can from the original input.
|
|
2357
|
+
const errProvider = typeof optionsOrPrompt === "object"
|
|
2358
|
+
? optionsOrPrompt.provider || "unknown"
|
|
2359
|
+
: "unknown";
|
|
2360
|
+
const errModel = typeof optionsOrPrompt === "object"
|
|
2361
|
+
? optionsOrPrompt.model || "unknown"
|
|
2362
|
+
: "unknown";
|
|
2363
|
+
try {
|
|
2364
|
+
this.emitter.emit("generation:end", {
|
|
2365
|
+
provider: errProvider,
|
|
2366
|
+
model: errModel,
|
|
2367
|
+
responseTime: 0,
|
|
2368
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2369
|
+
success: false,
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
catch (emitError) {
|
|
2373
|
+
void emitError; // non-blocking — error event emission is best-effort
|
|
2374
|
+
}
|
|
2307
2375
|
throw error;
|
|
2308
2376
|
}
|
|
2309
2377
|
finally {
|
|
@@ -4363,7 +4431,7 @@ Current user's request: ${currentInput}`;
|
|
|
4363
4431
|
// Accept audio when frames are present; sampleRateHz is optional (defaults applied later)
|
|
4364
4432
|
const hasAudio = !!(options?.input?.audio &&
|
|
4365
4433
|
options.input.audio.frames &&
|
|
4366
|
-
typeof options.input.audio.frames[Symbol.asyncIterator]
|
|
4434
|
+
typeof options.input.audio.frames[Symbol.asyncIterator] === "function");
|
|
4367
4435
|
if (!hasText && !hasAudio) {
|
|
4368
4436
|
throw new Error("Stream options must include either input.text or input.audio");
|
|
4369
4437
|
}
|
|
@@ -5085,8 +5153,7 @@ Current user's request: ${currentInput}`;
|
|
|
5085
5153
|
}
|
|
5086
5154
|
// Check if the memory manager is Redis-backed (has updateAgenticLoopReport method)
|
|
5087
5155
|
if (!("updateAgenticLoopReport" in this.conversationMemory) ||
|
|
5088
|
-
typeof this.conversationMemory
|
|
5089
|
-
.updateAgenticLoopReport !== "function") {
|
|
5156
|
+
typeof this.conversationMemory.updateAgenticLoopReport !== "function") {
|
|
5090
5157
|
throw new ConversationMemoryError("updateAgenticLoopReport is only supported with Redis conversation memory.", "CONFIG_ERROR");
|
|
5091
5158
|
}
|
|
5092
5159
|
await withTimeout(this
|
|
@@ -5787,7 +5854,6 @@ Current user's request: ${currentInput}`;
|
|
|
5787
5854
|
}
|
|
5788
5855
|
const responseData = await response.json();
|
|
5789
5856
|
const models = responseData?.models;
|
|
5790
|
-
const defaultOllamaModel = "llama3.2:latest";
|
|
5791
5857
|
// Runtime-safe guard: ensure models is an array with valid objects
|
|
5792
5858
|
if (!Array.isArray(models)) {
|
|
5793
5859
|
logger.warn("Ollama API returned invalid models format in testProvider", {
|
|
@@ -5798,15 +5864,14 @@ Current user's request: ${currentInput}`;
|
|
|
5798
5864
|
}
|
|
5799
5865
|
// Filter and validate models before comparison
|
|
5800
5866
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
5801
|
-
|
|
5802
|
-
if (modelIsAvailable) {
|
|
5867
|
+
if (validModels.length > 0) {
|
|
5803
5868
|
return {
|
|
5804
5869
|
provider: providerName,
|
|
5805
5870
|
status: "working",
|
|
5806
5871
|
configured: true,
|
|
5807
5872
|
authenticated: true,
|
|
5808
5873
|
responseTime: Date.now() - startTime,
|
|
5809
|
-
model:
|
|
5874
|
+
model: validModels[0].name,
|
|
5810
5875
|
};
|
|
5811
5876
|
}
|
|
5812
5877
|
else {
|
|
@@ -5815,7 +5880,7 @@ Current user's request: ${currentInput}`;
|
|
|
5815
5880
|
status: "failed",
|
|
5816
5881
|
configured: true,
|
|
5817
5882
|
authenticated: false,
|
|
5818
|
-
error:
|
|
5883
|
+
error: "Ollama service running but no models installed",
|
|
5819
5884
|
responseTime: Date.now() - startTime,
|
|
5820
5885
|
};
|
|
5821
5886
|
}
|
|
@@ -118,7 +118,9 @@ export class LangfuseExporter extends BaseExporter {
|
|
|
118
118
|
name: span.name,
|
|
119
119
|
userId: span.attributes["user.id"],
|
|
120
120
|
sessionId: span.attributes["session.id"],
|
|
121
|
-
metadata
|
|
121
|
+
// Only pick safe, non-PII attributes for metadata — intentionally excludes
|
|
122
|
+
// input, output, error.stack, and other user content to match Braintrust exporter
|
|
123
|
+
metadata: filterSafeMetadata(span.attributes),
|
|
122
124
|
release: this.release,
|
|
123
125
|
tags: this.extractTags(span),
|
|
124
126
|
};
|
|
@@ -197,4 +199,6 @@ export class LangfuseExporter extends BaseExporter {
|
|
|
197
199
|
return tags;
|
|
198
200
|
}
|
|
199
201
|
}
|
|
202
|
+
// Safe metadata filtering imported from shared module to avoid duplication
|
|
203
|
+
import { filterSafeMetadata } from "../utils/safeMetadata.js";
|
|
200
204
|
//# sourceMappingURL=langfuseExporter.js.map
|
|
@@ -35,8 +35,12 @@ export class MetricsAggregator {
|
|
|
35
35
|
recordSpan(span) {
|
|
36
36
|
// Enforce maximum spans limit
|
|
37
37
|
if (this.spans.length >= this.config.maxSpansRetained) {
|
|
38
|
-
this.spans.shift(); // Remove oldest span
|
|
39
|
-
//
|
|
38
|
+
const evicted = this.spans.shift(); // Remove oldest span
|
|
39
|
+
// Only trim latencyValues when the evicted span had a duration recorded
|
|
40
|
+
if (evicted?.durationMs !== undefined) {
|
|
41
|
+
this.latencyValues.shift();
|
|
42
|
+
}
|
|
43
|
+
// Note: We keep aggregated metrics, only raw spans and latency values are trimmed
|
|
40
44
|
}
|
|
41
45
|
this.spans.push(span);
|
|
42
46
|
// Update timestamps
|
|
@@ -66,6 +66,8 @@ export class RedactionProcessor {
|
|
|
66
66
|
"credentials",
|
|
67
67
|
"private_key",
|
|
68
68
|
"privateKey",
|
|
69
|
+
"stack",
|
|
70
|
+
"error.stack",
|
|
69
71
|
]);
|
|
70
72
|
this.redactedValue = config?.redactedValue ?? "[REDACTED]";
|
|
71
73
|
}
|
|
@@ -228,11 +230,25 @@ export class BatchProcessor {
|
|
|
228
230
|
this.flush();
|
|
229
231
|
}, this.flushIntervalMs);
|
|
230
232
|
}
|
|
233
|
+
// Note: flush() is intentionally synchronous. The onBatchReady callback is
|
|
234
|
+
// typed as `(spans: SpanData[]) => void` — callers must not pass async
|
|
235
|
+
// exporters. If async export is needed, the callback should handle its own
|
|
236
|
+
// error reporting (e.g. fire-and-forget with promise error handlers).
|
|
231
237
|
flush() {
|
|
232
238
|
if (this.batch.length > 0 && this.onBatchReady) {
|
|
233
239
|
const spans = [...this.batch];
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
try {
|
|
241
|
+
this.onBatchReady(spans);
|
|
242
|
+
this.batch = [];
|
|
243
|
+
}
|
|
244
|
+
catch (flushError) {
|
|
245
|
+
// Keep spans for next flush attempt, but cap backlog growth
|
|
246
|
+
void flushError; // acknowledged — error is expected during exporter outages
|
|
247
|
+
const maxBacklog = this.batchSize * 20;
|
|
248
|
+
if (this.batch.length > maxBacklog) {
|
|
249
|
+
this.batch = this.batch.slice(this.batch.length - maxBacklog);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
254
|
async shutdown() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe metadata filtering for observability exporters.
|
|
3
|
+
*
|
|
4
|
+
* Only these attribute keys are forwarded to third-party backends as trace
|
|
5
|
+
* metadata. User prompts (input), LLM responses (output), error stacks, and
|
|
6
|
+
* any other potentially sensitive data are excluded to prevent PII leaks.
|
|
7
|
+
*/
|
|
8
|
+
import type { SpanAttributes } from "../types/spanTypes.js";
|
|
9
|
+
export declare const SAFE_METADATA_KEYS: Set<string>;
|
|
10
|
+
export declare function filterSafeMetadata(attributes: SpanAttributes): Record<string, unknown>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe metadata filtering for observability exporters.
|
|
3
|
+
*
|
|
4
|
+
* Only these attribute keys are forwarded to third-party backends as trace
|
|
5
|
+
* metadata. User prompts (input), LLM responses (output), error stacks, and
|
|
6
|
+
* any other potentially sensitive data are excluded to prevent PII leaks.
|
|
7
|
+
*/
|
|
8
|
+
// Only ai.* keys are forwarded as metadata. Stream metrics (chunk_count,
|
|
9
|
+
// content_length) should be accessed via span attributes directly, not via
|
|
10
|
+
// metadata sent to third-party backends.
|
|
11
|
+
export const SAFE_METADATA_KEYS = new Set([
|
|
12
|
+
"ai.provider",
|
|
13
|
+
"ai.model",
|
|
14
|
+
"ai.temperature",
|
|
15
|
+
"ai.max_tokens",
|
|
16
|
+
]);
|
|
17
|
+
export function filterSafeMetadata(attributes) {
|
|
18
|
+
const filtered = {};
|
|
19
|
+
for (const key of SAFE_METADATA_KEYS) {
|
|
20
|
+
if (attributes[key] !== undefined) {
|
|
21
|
+
filtered[key] = attributes[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return filtered;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=safeMetadata.js.map
|
|
@@ -104,7 +104,9 @@ export class SpanSerializer {
|
|
|
104
104
|
name: span.name,
|
|
105
105
|
startTime: span.startTime,
|
|
106
106
|
endTime: span.endTime,
|
|
107
|
-
|
|
107
|
+
// Only pick safe, non-PII attributes for metadata — intentionally excludes
|
|
108
|
+
// input, output, error.stack, and other user content for PII safety
|
|
109
|
+
metadata: filterSafeMetadata(span.attributes),
|
|
108
110
|
level: span.status === SpanStatus.ERROR ? "ERROR" : "DEFAULT",
|
|
109
111
|
statusMessage: span.statusMessage,
|
|
110
112
|
input: span.attributes["input"],
|
|
@@ -284,4 +286,6 @@ export class SpanSerializer {
|
|
|
284
286
|
});
|
|
285
287
|
}
|
|
286
288
|
}
|
|
289
|
+
// Safe metadata filtering imported from shared module to avoid duplication
|
|
290
|
+
import { filterSafeMetadata } from "./safeMetadata.js";
|
|
287
291
|
//# sourceMappingURL=spanSerializer.js.map
|
|
@@ -1259,9 +1259,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1259
1259
|
},
|
|
1260
1260
|
{
|
|
1261
1261
|
role: "model",
|
|
1262
|
-
parts: [
|
|
1263
|
-
{ text: "Understood. I will follow these instructions." },
|
|
1264
|
-
],
|
|
1262
|
+
parts: [{ text: "OK" }],
|
|
1265
1263
|
},
|
|
1266
1264
|
...currentContents,
|
|
1267
1265
|
];
|
|
@@ -1435,9 +1433,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1435
1433
|
},
|
|
1436
1434
|
{
|
|
1437
1435
|
role: "model",
|
|
1438
|
-
parts: [
|
|
1439
|
-
{ text: "Understood. I will follow these instructions." },
|
|
1440
|
-
],
|
|
1436
|
+
parts: [{ text: "OK" }],
|
|
1441
1437
|
},
|
|
1442
1438
|
...currentContents,
|
|
1443
1439
|
];
|
package/dist/neurolink.js
CHANGED
|
@@ -1263,18 +1263,42 @@ Current user's request: ${currentInput}`;
|
|
|
1263
1263
|
}
|
|
1264
1264
|
// Filter and validate models before comparison
|
|
1265
1265
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
1266
|
-
const targetModel = route.model
|
|
1267
|
-
|
|
1268
|
-
|
|
1266
|
+
const targetModel = route.model;
|
|
1267
|
+
if (targetModel) {
|
|
1268
|
+
// Check if the specific routed model is available
|
|
1269
|
+
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
|
|
1270
|
+
if (!modelIsAvailable) {
|
|
1271
|
+
logger.debug("Orchestration provider validation failed", {
|
|
1272
|
+
taskType: classification.type,
|
|
1273
|
+
routedProvider: route.provider,
|
|
1274
|
+
routedModel: route.model,
|
|
1275
|
+
reason: `Ollama model '${targetModel}' not found`,
|
|
1276
|
+
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1277
|
+
});
|
|
1278
|
+
// Fall back to first available model instead of abandoning orchestration
|
|
1279
|
+
if (validModels.length > 0) {
|
|
1280
|
+
route.model = validModels[0].name;
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
return {}; // No models at all — preserve existing fallback behavior
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
else if (validModels.length === 0) {
|
|
1288
|
+
// No model specified and none available
|
|
1269
1289
|
logger.debug("Orchestration provider validation failed", {
|
|
1270
1290
|
taskType: classification.type,
|
|
1271
1291
|
routedProvider: route.provider,
|
|
1272
1292
|
routedModel: route.model,
|
|
1273
|
-
reason:
|
|
1293
|
+
reason: "No Ollama models available",
|
|
1274
1294
|
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1275
1295
|
});
|
|
1276
1296
|
return {}; // Return empty object to preserve existing fallback behavior
|
|
1277
1297
|
}
|
|
1298
|
+
else {
|
|
1299
|
+
// No model specified but models are available — use the first one
|
|
1300
|
+
route.model = validModels[0].name;
|
|
1301
|
+
}
|
|
1278
1302
|
}
|
|
1279
1303
|
catch (error) {
|
|
1280
1304
|
logger.debug("Orchestration provider validation failed", {
|
|
@@ -1377,18 +1401,42 @@ Current user's request: ${currentInput}`;
|
|
|
1377
1401
|
}
|
|
1378
1402
|
// Filter and validate models before comparison
|
|
1379
1403
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
1380
|
-
const targetModel = route.model
|
|
1381
|
-
|
|
1382
|
-
|
|
1404
|
+
const targetModel = route.model;
|
|
1405
|
+
if (targetModel) {
|
|
1406
|
+
// Check if the specific routed model is available
|
|
1407
|
+
const modelIsAvailable = validModels.some((m) => m.name === targetModel);
|
|
1408
|
+
if (!modelIsAvailable) {
|
|
1409
|
+
logger.debug("Stream orchestration provider validation failed", {
|
|
1410
|
+
taskType: classification.type,
|
|
1411
|
+
routedProvider: route.provider,
|
|
1412
|
+
routedModel: route.model,
|
|
1413
|
+
reason: `Ollama model '${targetModel}' not found`,
|
|
1414
|
+
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1415
|
+
});
|
|
1416
|
+
// Fall back to first available model instead of abandoning orchestration
|
|
1417
|
+
if (validModels.length > 0) {
|
|
1418
|
+
route.model = validModels[0].name;
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
return {}; // No models at all — preserve existing fallback behavior
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
else if (validModels.length === 0) {
|
|
1426
|
+
// No model specified and none available
|
|
1383
1427
|
logger.debug("Stream orchestration provider validation failed", {
|
|
1384
1428
|
taskType: classification.type,
|
|
1385
1429
|
routedProvider: route.provider,
|
|
1386
1430
|
routedModel: route.model,
|
|
1387
|
-
reason:
|
|
1431
|
+
reason: "No Ollama models available",
|
|
1388
1432
|
orchestrationTime: `${Date.now() - startTime}ms`,
|
|
1389
1433
|
});
|
|
1390
1434
|
return {}; // Return empty object to preserve existing fallback behavior
|
|
1391
1435
|
}
|
|
1436
|
+
else {
|
|
1437
|
+
// No model specified but models are available — use the first one
|
|
1438
|
+
route.model = validModels[0].name;
|
|
1439
|
+
}
|
|
1392
1440
|
}
|
|
1393
1441
|
catch (error) {
|
|
1394
1442
|
logger.debug("Stream orchestration provider validation failed", {
|
|
@@ -1693,7 +1741,11 @@ Current user's request: ${currentInput}`;
|
|
|
1693
1741
|
span.spanId = traceCtx.parentSpanId;
|
|
1694
1742
|
span.parentSpanId = undefined;
|
|
1695
1743
|
}
|
|
1696
|
-
|
|
1744
|
+
// Mark failed generations with ERROR status so metrics count them correctly
|
|
1745
|
+
const spanStatus = data.success === false || data.error
|
|
1746
|
+
? SpanStatus.ERROR
|
|
1747
|
+
: SpanStatus.OK;
|
|
1748
|
+
span = SpanSerializer.endSpan(span, spanStatus, data.error ? String(data.error) : undefined);
|
|
1697
1749
|
span.durationMs = responseTime;
|
|
1698
1750
|
if (usage) {
|
|
1699
1751
|
span = SpanSerializer.enrichWithTokenUsage(span, {
|
|
@@ -2261,19 +2313,14 @@ Current user's request: ${currentInput}`;
|
|
|
2261
2313
|
evaluation: textResult.evaluation
|
|
2262
2314
|
? {
|
|
2263
2315
|
...textResult.evaluation,
|
|
2264
|
-
isOffTopic: textResult.evaluation
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
evaluationTime: textResult.evaluation
|
|
2273
|
-
.evaluationTime ?? Date.now(),
|
|
2274
|
-
// Include evaluationDomain from original options
|
|
2275
|
-
evaluationDomain: textResult.evaluation
|
|
2276
|
-
.evaluationDomain ??
|
|
2316
|
+
isOffTopic: textResult.evaluation.isOffTopic ?? false,
|
|
2317
|
+
alertSeverity: textResult.evaluation.alertSeverity ??
|
|
2318
|
+
"none",
|
|
2319
|
+
reasoning: textResult.evaluation.reasoning ??
|
|
2320
|
+
"No evaluation provided",
|
|
2321
|
+
evaluationModel: textResult.evaluation.evaluationModel ?? "unknown",
|
|
2322
|
+
evaluationTime: textResult.evaluation.evaluationTime ?? Date.now(),
|
|
2323
|
+
evaluationDomain: textResult.evaluation.evaluationDomain ??
|
|
2277
2324
|
textOptions.evaluationDomain ??
|
|
2278
2325
|
factoryResult.domainType,
|
|
2279
2326
|
}
|
|
@@ -2304,6 +2351,27 @@ Current user's request: ${currentInput}`;
|
|
|
2304
2351
|
code: SpanStatusCode.ERROR,
|
|
2305
2352
|
message: error instanceof Error ? error.message : String(error),
|
|
2306
2353
|
});
|
|
2354
|
+
// Emit generation:end on error so metrics listeners still record the failure.
|
|
2355
|
+
// Note: variables declared inside try blocks are not accessible in error
|
|
2356
|
+
// handlers, so we extract what we can from the original input.
|
|
2357
|
+
const errProvider = typeof optionsOrPrompt === "object"
|
|
2358
|
+
? optionsOrPrompt.provider || "unknown"
|
|
2359
|
+
: "unknown";
|
|
2360
|
+
const errModel = typeof optionsOrPrompt === "object"
|
|
2361
|
+
? optionsOrPrompt.model || "unknown"
|
|
2362
|
+
: "unknown";
|
|
2363
|
+
try {
|
|
2364
|
+
this.emitter.emit("generation:end", {
|
|
2365
|
+
provider: errProvider,
|
|
2366
|
+
model: errModel,
|
|
2367
|
+
responseTime: 0,
|
|
2368
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2369
|
+
success: false,
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
catch (emitError) {
|
|
2373
|
+
void emitError; // non-blocking — error event emission is best-effort
|
|
2374
|
+
}
|
|
2307
2375
|
throw error;
|
|
2308
2376
|
}
|
|
2309
2377
|
finally {
|
|
@@ -4363,7 +4431,7 @@ Current user's request: ${currentInput}`;
|
|
|
4363
4431
|
// Accept audio when frames are present; sampleRateHz is optional (defaults applied later)
|
|
4364
4432
|
const hasAudio = !!(options?.input?.audio &&
|
|
4365
4433
|
options.input.audio.frames &&
|
|
4366
|
-
typeof options.input.audio.frames[Symbol.asyncIterator]
|
|
4434
|
+
typeof options.input.audio.frames[Symbol.asyncIterator] === "function");
|
|
4367
4435
|
if (!hasText && !hasAudio) {
|
|
4368
4436
|
throw new Error("Stream options must include either input.text or input.audio");
|
|
4369
4437
|
}
|
|
@@ -5085,8 +5153,7 @@ Current user's request: ${currentInput}`;
|
|
|
5085
5153
|
}
|
|
5086
5154
|
// Check if the memory manager is Redis-backed (has updateAgenticLoopReport method)
|
|
5087
5155
|
if (!("updateAgenticLoopReport" in this.conversationMemory) ||
|
|
5088
|
-
typeof this.conversationMemory
|
|
5089
|
-
.updateAgenticLoopReport !== "function") {
|
|
5156
|
+
typeof this.conversationMemory.updateAgenticLoopReport !== "function") {
|
|
5090
5157
|
throw new ConversationMemoryError("updateAgenticLoopReport is only supported with Redis conversation memory.", "CONFIG_ERROR");
|
|
5091
5158
|
}
|
|
5092
5159
|
await withTimeout(this
|
|
@@ -5787,7 +5854,6 @@ Current user's request: ${currentInput}`;
|
|
|
5787
5854
|
}
|
|
5788
5855
|
const responseData = await response.json();
|
|
5789
5856
|
const models = responseData?.models;
|
|
5790
|
-
const defaultOllamaModel = "llama3.2:latest";
|
|
5791
5857
|
// Runtime-safe guard: ensure models is an array with valid objects
|
|
5792
5858
|
if (!Array.isArray(models)) {
|
|
5793
5859
|
logger.warn("Ollama API returned invalid models format in testProvider", {
|
|
@@ -5798,15 +5864,14 @@ Current user's request: ${currentInput}`;
|
|
|
5798
5864
|
}
|
|
5799
5865
|
// Filter and validate models before comparison
|
|
5800
5866
|
const validModels = models.filter((m) => m && typeof m === "object" && typeof m.name === "string");
|
|
5801
|
-
|
|
5802
|
-
if (modelIsAvailable) {
|
|
5867
|
+
if (validModels.length > 0) {
|
|
5803
5868
|
return {
|
|
5804
5869
|
provider: providerName,
|
|
5805
5870
|
status: "working",
|
|
5806
5871
|
configured: true,
|
|
5807
5872
|
authenticated: true,
|
|
5808
5873
|
responseTime: Date.now() - startTime,
|
|
5809
|
-
model:
|
|
5874
|
+
model: validModels[0].name,
|
|
5810
5875
|
};
|
|
5811
5876
|
}
|
|
5812
5877
|
else {
|
|
@@ -5815,7 +5880,7 @@ Current user's request: ${currentInput}`;
|
|
|
5815
5880
|
status: "failed",
|
|
5816
5881
|
configured: true,
|
|
5817
5882
|
authenticated: false,
|
|
5818
|
-
error:
|
|
5883
|
+
error: "Ollama service running but no models installed",
|
|
5819
5884
|
responseTime: Date.now() - startTime,
|
|
5820
5885
|
};
|
|
5821
5886
|
}
|
|
@@ -118,7 +118,9 @@ export class LangfuseExporter extends BaseExporter {
|
|
|
118
118
|
name: span.name,
|
|
119
119
|
userId: span.attributes["user.id"],
|
|
120
120
|
sessionId: span.attributes["session.id"],
|
|
121
|
-
metadata
|
|
121
|
+
// Only pick safe, non-PII attributes for metadata — intentionally excludes
|
|
122
|
+
// input, output, error.stack, and other user content to match Braintrust exporter
|
|
123
|
+
metadata: filterSafeMetadata(span.attributes),
|
|
122
124
|
release: this.release,
|
|
123
125
|
tags: this.extractTags(span),
|
|
124
126
|
};
|
|
@@ -197,3 +199,5 @@ export class LangfuseExporter extends BaseExporter {
|
|
|
197
199
|
return tags;
|
|
198
200
|
}
|
|
199
201
|
}
|
|
202
|
+
// Safe metadata filtering imported from shared module to avoid duplication
|
|
203
|
+
import { filterSafeMetadata } from "../utils/safeMetadata.js";
|
|
@@ -35,8 +35,12 @@ export class MetricsAggregator {
|
|
|
35
35
|
recordSpan(span) {
|
|
36
36
|
// Enforce maximum spans limit
|
|
37
37
|
if (this.spans.length >= this.config.maxSpansRetained) {
|
|
38
|
-
this.spans.shift(); // Remove oldest span
|
|
39
|
-
//
|
|
38
|
+
const evicted = this.spans.shift(); // Remove oldest span
|
|
39
|
+
// Only trim latencyValues when the evicted span had a duration recorded
|
|
40
|
+
if (evicted?.durationMs !== undefined) {
|
|
41
|
+
this.latencyValues.shift();
|
|
42
|
+
}
|
|
43
|
+
// Note: We keep aggregated metrics, only raw spans and latency values are trimmed
|
|
40
44
|
}
|
|
41
45
|
this.spans.push(span);
|
|
42
46
|
// Update timestamps
|
|
@@ -66,6 +66,8 @@ export class RedactionProcessor {
|
|
|
66
66
|
"credentials",
|
|
67
67
|
"private_key",
|
|
68
68
|
"privateKey",
|
|
69
|
+
"stack",
|
|
70
|
+
"error.stack",
|
|
69
71
|
]);
|
|
70
72
|
this.redactedValue = config?.redactedValue ?? "[REDACTED]";
|
|
71
73
|
}
|
|
@@ -228,11 +230,25 @@ export class BatchProcessor {
|
|
|
228
230
|
this.flush();
|
|
229
231
|
}, this.flushIntervalMs);
|
|
230
232
|
}
|
|
233
|
+
// Note: flush() is intentionally synchronous. The onBatchReady callback is
|
|
234
|
+
// typed as `(spans: SpanData[]) => void` — callers must not pass async
|
|
235
|
+
// exporters. If async export is needed, the callback should handle its own
|
|
236
|
+
// error reporting (e.g. fire-and-forget with promise error handlers).
|
|
231
237
|
flush() {
|
|
232
238
|
if (this.batch.length > 0 && this.onBatchReady) {
|
|
233
239
|
const spans = [...this.batch];
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
try {
|
|
241
|
+
this.onBatchReady(spans);
|
|
242
|
+
this.batch = [];
|
|
243
|
+
}
|
|
244
|
+
catch (flushError) {
|
|
245
|
+
// Keep spans for next flush attempt, but cap backlog growth
|
|
246
|
+
void flushError; // acknowledged — error is expected during exporter outages
|
|
247
|
+
const maxBacklog = this.batchSize * 20;
|
|
248
|
+
if (this.batch.length > maxBacklog) {
|
|
249
|
+
this.batch = this.batch.slice(this.batch.length - maxBacklog);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
236
252
|
}
|
|
237
253
|
}
|
|
238
254
|
async shutdown() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe metadata filtering for observability exporters.
|
|
3
|
+
*
|
|
4
|
+
* Only these attribute keys are forwarded to third-party backends as trace
|
|
5
|
+
* metadata. User prompts (input), LLM responses (output), error stacks, and
|
|
6
|
+
* any other potentially sensitive data are excluded to prevent PII leaks.
|
|
7
|
+
*/
|
|
8
|
+
import type { SpanAttributes } from "../types/spanTypes.js";
|
|
9
|
+
export declare const SAFE_METADATA_KEYS: Set<string>;
|
|
10
|
+
export declare function filterSafeMetadata(attributes: SpanAttributes): Record<string, unknown>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe metadata filtering for observability exporters.
|
|
3
|
+
*
|
|
4
|
+
* Only these attribute keys are forwarded to third-party backends as trace
|
|
5
|
+
* metadata. User prompts (input), LLM responses (output), error stacks, and
|
|
6
|
+
* any other potentially sensitive data are excluded to prevent PII leaks.
|
|
7
|
+
*/
|
|
8
|
+
// Only ai.* keys are forwarded as metadata. Stream metrics (chunk_count,
|
|
9
|
+
// content_length) should be accessed via span attributes directly, not via
|
|
10
|
+
// metadata sent to third-party backends.
|
|
11
|
+
export const SAFE_METADATA_KEYS = new Set([
|
|
12
|
+
"ai.provider",
|
|
13
|
+
"ai.model",
|
|
14
|
+
"ai.temperature",
|
|
15
|
+
"ai.max_tokens",
|
|
16
|
+
]);
|
|
17
|
+
export function filterSafeMetadata(attributes) {
|
|
18
|
+
const filtered = {};
|
|
19
|
+
for (const key of SAFE_METADATA_KEYS) {
|
|
20
|
+
if (attributes[key] !== undefined) {
|
|
21
|
+
filtered[key] = attributes[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return filtered;
|
|
25
|
+
}
|
|
@@ -104,7 +104,9 @@ export class SpanSerializer {
|
|
|
104
104
|
name: span.name,
|
|
105
105
|
startTime: span.startTime,
|
|
106
106
|
endTime: span.endTime,
|
|
107
|
-
|
|
107
|
+
// Only pick safe, non-PII attributes for metadata — intentionally excludes
|
|
108
|
+
// input, output, error.stack, and other user content for PII safety
|
|
109
|
+
metadata: filterSafeMetadata(span.attributes),
|
|
108
110
|
level: span.status === SpanStatus.ERROR ? "ERROR" : "DEFAULT",
|
|
109
111
|
statusMessage: span.statusMessage,
|
|
110
112
|
input: span.attributes["input"],
|
|
@@ -284,3 +286,5 @@ export class SpanSerializer {
|
|
|
284
286
|
});
|
|
285
287
|
}
|
|
286
288
|
}
|
|
289
|
+
// Safe metadata filtering imported from shared module to avoid duplication
|
|
290
|
+
import { filterSafeMetadata } from "./safeMetadata.js";
|
|
@@ -1259,9 +1259,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1259
1259
|
},
|
|
1260
1260
|
{
|
|
1261
1261
|
role: "model",
|
|
1262
|
-
parts: [
|
|
1263
|
-
{ text: "Understood. I will follow these instructions." },
|
|
1264
|
-
],
|
|
1262
|
+
parts: [{ text: "OK" }],
|
|
1265
1263
|
},
|
|
1266
1264
|
...currentContents,
|
|
1267
1265
|
];
|
|
@@ -1435,9 +1433,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1435
1433
|
},
|
|
1436
1434
|
{
|
|
1437
1435
|
role: "model",
|
|
1438
|
-
parts: [
|
|
1439
|
-
{ text: "Understood. I will follow these instructions." },
|
|
1440
|
-
],
|
|
1436
|
+
parts: [{ text: "OK" }],
|
|
1441
1437
|
},
|
|
1442
1438
|
...currentContents,
|
|
1443
1439
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "9.25.
|
|
3
|
+
"version": "9.25.2",
|
|
4
4
|
"description": "Universal AI Development Platform with working MCP integration, multi-provider support, and professional CLI. Built-in tools operational, 58+ external MCP servers discoverable. Connect to filesystem, GitHub, database operations, and more. Build, test, and deploy AI applications with 13 providers: OpenAI, Anthropic, Google AI, AWS Bedrock, Azure, Hugging Face, Ollama, and Mistral AI.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Juspay Technologies",
|