@juspay/neurolink 11.25.3 → 11.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +395 -395
- package/dist/core/baseProvider.d.ts +55 -4
- package/dist/core/baseProvider.js +216 -59
- package/dist/localUsage/claudeCodeReader.js +2 -6
- package/dist/localUsage/codexReader.js +2 -6
- package/dist/localUsage/openCodeReader.js +4 -6
- package/dist/localUsage/scanWindow.d.ts +29 -0
- package/dist/localUsage/scanWindow.js +42 -0
- package/dist/neurolink.d.ts +7 -20
- package/dist/neurolink.js +99 -85
- package/dist/providers/anthropic/client.js +22 -1
- package/dist/types/stream.d.ts +44 -6
- package/dist/types/tts.d.ts +9 -0
- package/dist/types/tts.js +6 -0
- package/dist/utils/ttsProcessor.d.ts +20 -1
- package/dist/utils/ttsProcessor.js +167 -0
- package/dist/utils/ttsStream.d.ts +15 -0
- package/dist/utils/ttsStream.js +225 -0
- package/package.json +1 -1
|
@@ -4,10 +4,6 @@ import type { NeuroLink } from "../neurolink.js";
|
|
|
4
4
|
import type { UnknownRecord, MiddlewareFactoryOptions, StreamOptions, StreamResult, AIProvider, AnalyticsData, EnhancedGenerateResult, TextGenerationOptions, TextGenerationResult, ValidationSchema } from "../types/index.js";
|
|
5
5
|
import { TelemetryHandler } from "./modules/TelemetryHandler.js";
|
|
6
6
|
import type { LanguageModel, ModelMessage, Tool, ToolCallRepairFunction, ToolSet } from "../types/index.js";
|
|
7
|
-
/**
|
|
8
|
-
* Abstract base class for all AI providers
|
|
9
|
-
* Tools are integrated as first-class citizens - always available by default
|
|
10
|
-
*/
|
|
11
7
|
export declare abstract class BaseProvider implements AIProvider {
|
|
12
8
|
protected modelName: string;
|
|
13
9
|
protected readonly providerName: AIProviderName;
|
|
@@ -76,6 +72,11 @@ export declare abstract class BaseProvider implements AIProvider {
|
|
|
76
72
|
* that supplied `onError`).
|
|
77
73
|
*/
|
|
78
74
|
private fireLifecycleErrorCallback;
|
|
75
|
+
/**
|
|
76
|
+
* Build the fake-stream output and apply the same incremental TTS wrapper
|
|
77
|
+
* used by the standard NeuroLink stream path.
|
|
78
|
+
*/
|
|
79
|
+
private createFakeStreamingOutput;
|
|
79
80
|
/**
|
|
80
81
|
* Execute fake streaming - extracted method for reusability
|
|
81
82
|
*/
|
|
@@ -387,6 +388,56 @@ export declare abstract class BaseProvider implements AIProvider {
|
|
|
387
388
|
* original identity so that isAbortError() can detect them in
|
|
388
389
|
* retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
|
|
389
390
|
*/
|
|
391
|
+
/**
|
|
392
|
+
* Classify an error that escaped while the consumer was ITERATING a stream.
|
|
393
|
+
*
|
|
394
|
+
* `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
|
|
395
|
+
* that discovers its failure lazily throws on first pull instead — so the raw upstream
|
|
396
|
+
* error reached the consumer with no provider tag and no classification, while the same
|
|
397
|
+
* failure through `generate()` was classified normally. Measured on OpenAI:
|
|
398
|
+
*
|
|
399
|
+
* streaming "You have no credits remaining."
|
|
400
|
+
* non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
|
|
401
|
+
*
|
|
402
|
+
* Three guards. Only the third is load-bearing against today's code; the first two are
|
|
403
|
+
* deliberate depth against a hazard that is real but not currently reachable. Measured,
|
|
404
|
+
* rather than assumed — see the note after the list.
|
|
405
|
+
*
|
|
406
|
+
* 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
|
|
407
|
+
* including the two formatters whose results escape the ProviderError hierarchy
|
|
408
|
+
* (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
|
|
409
|
+
* extend Error directly). Without this they would be classified a second time and
|
|
410
|
+
* degraded.
|
|
411
|
+
* 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
|
|
412
|
+
* bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
|
|
413
|
+
* own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
|
|
414
|
+
* 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
|
|
415
|
+
* failure. `classifyProviderError` ends in an unconditional catch-all
|
|
416
|
+
* (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
|
|
417
|
+
* gated on the error having come off the wire. Measured with the guard absent:
|
|
418
|
+
* TypeError "Cannot read properties of undefined (reading 'content')"
|
|
419
|
+
* became ProviderError "[openai] openai error: Cannot read properties of undefined..."
|
|
420
|
+
* which would hide a real defect behind a plausible provider message.
|
|
421
|
+
*
|
|
422
|
+
* WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
|
|
423
|
+
* attached to a formatted error in exactly one place — `handleProviderError` below — and
|
|
424
|
+
* that same method applies the stamp. So a ProviderError carrying a status is always
|
|
425
|
+
* stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
|
|
426
|
+
* `formatProviderError` call carries no status, so guard 3 already returns it untouched.
|
|
427
|
+
* Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
|
|
428
|
+
* provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
|
|
429
|
+
* byte-identical (`RateLimitError`, one `[anthropic]` prefix).
|
|
430
|
+
*
|
|
431
|
+
* They remain because the hazard they cover is measured and real: `handleProviderError`
|
|
432
|
+
* is NOT idempotent — it copies statusCode onto its own output, so a second pass
|
|
433
|
+
* re-matches the bare 429 rule and DEGRADES the classification:
|
|
434
|
+
* pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
|
|
435
|
+
* pass 2 RateLimitError "...rate limit exceeded..."
|
|
436
|
+
* The day any provider attaches a status to an error it formats itself, guard 3 stops
|
|
437
|
+
* covering that case and this degradation becomes live. Cheap insurance, not dead code —
|
|
438
|
+
* but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
|
|
439
|
+
*/
|
|
440
|
+
protected classifyStreamError(error: unknown): Error;
|
|
390
441
|
protected handleProviderError(error: unknown): Error;
|
|
391
442
|
/**
|
|
392
443
|
* Image generation method. Providers that support it should override this.
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
2
|
import { directAgentTools } from "../agent/directTools.js";
|
|
3
|
-
import { isImageGenerationModel } from "
|
|
3
|
+
import { isImageGenerationModel } from "./constants.js";
|
|
4
4
|
import { MiddlewareFactory } from "../middleware/factory.js";
|
|
5
5
|
import { modelSupports } from "../models/modelRegistry.js";
|
|
6
6
|
import { ATTR, tracers } from "../telemetry/index.js";
|
|
7
7
|
import { ERROR_CODES, isAbortError, NeuroLinkError, } from "../utils/errorHandling.js";
|
|
8
|
+
import { ProviderError } from "../types/index.js";
|
|
8
9
|
import { sanitizeErrorCause } from "../utils/logSanitize.js";
|
|
9
10
|
import { createAnalytics as buildAnalytics } from "./analytics.js";
|
|
10
11
|
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
|
|
@@ -12,6 +13,7 @@ import { duckTypedStatusCode, extractRetryAfterMsFromError, } from "../utils/pro
|
|
|
12
13
|
import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifecycleCallbacks.js";
|
|
13
14
|
import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
|
|
14
15
|
import { logger } from "../utils/logger.js";
|
|
16
|
+
import { interleaveTTSStream } from "../utils/ttsStream.js";
|
|
15
17
|
import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
|
|
16
18
|
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
17
19
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
@@ -48,6 +50,47 @@ function getLifecycleMiddlewareConfig(options) {
|
|
|
48
50
|
* Abstract base class for all AI providers
|
|
49
51
|
* Tools are integrated as first-class citizens - always available by default
|
|
50
52
|
*/
|
|
53
|
+
/**
|
|
54
|
+
* Marks an error as already run through `formatProviderError`.
|
|
55
|
+
*
|
|
56
|
+
* `handleProviderError` is NOT idempotent — measured: feeding its own output back in
|
|
57
|
+
* degrades a specific classification into a generic one, because it copies `statusCode`
|
|
58
|
+
* onto the formatted error, so a second pass re-matches the bare 429 rule while the more
|
|
59
|
+
* specific rule (which keyed on the raw body) no longer can:
|
|
60
|
+
*
|
|
61
|
+
* pass 1 ProviderError "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
|
|
62
|
+
* pass 2 RateLimitError "[openai] OpenAI rate limit exceeded. Please try again later."
|
|
63
|
+
*
|
|
64
|
+
* Since the stream path can now reach a classifier that other paths already reached, that
|
|
65
|
+
* second pass became possible and had to be made impossible.
|
|
66
|
+
*
|
|
67
|
+
* Same `Symbol.for` stamping technique as `utils/lifecycleCallbacks.ts` and for the same
|
|
68
|
+
* reason: it survives across module copies where a closed-over WeakSet would not, and a
|
|
69
|
+
* frozen error degrades to one extra classification rather than a throw.
|
|
70
|
+
*/
|
|
71
|
+
const PROVIDER_ERROR_CLASSIFIED = Symbol.for("neurolink.providerErrorClassified");
|
|
72
|
+
function markProviderErrorClassified(error) {
|
|
73
|
+
if (error === null || typeof error !== "object") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
Object.defineProperty(error, PROVIDER_ERROR_CLASSIFIED, {
|
|
78
|
+
value: true,
|
|
79
|
+
enumerable: false,
|
|
80
|
+
writable: false,
|
|
81
|
+
configurable: false,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Non-extensible error — worst case is one redundant classification.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function isProviderErrorClassified(error) {
|
|
89
|
+
if (error === null || typeof error !== "object") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return error[PROVIDER_ERROR_CLASSIFIED] === true;
|
|
93
|
+
}
|
|
51
94
|
export class BaseProvider {
|
|
52
95
|
// Not `readonly` because providers that auto-discover the model from a
|
|
53
96
|
// /v1/models endpoint (lm-studio, llamacpp) need to update modelName after
|
|
@@ -307,10 +350,14 @@ export class BaseProvider {
|
|
|
307
350
|
*/
|
|
308
351
|
wrapStreamWithLifecycleCallbacks(result, options) {
|
|
309
352
|
const lifecycle = getLifecycleMiddlewareConfig(options);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
353
|
+
// No early return when there are no callbacks. This wrapper is the only point
|
|
354
|
+
// every provider's stream passes through unconditionally (the real-streaming path
|
|
355
|
+
// plus all three fake-streaming paths), and returning `result` untouched here is
|
|
356
|
+
// exactly why a provider error that surfaces during ITERATION reached the consumer
|
|
357
|
+
// unclassified: the object handed back was the provider's own generator, by
|
|
358
|
+
// reference, and every layer below is a proven passthrough. The callbacks below are
|
|
359
|
+
// each individually guarded, so with none registered this only adds the catch.
|
|
360
|
+
const { onChunk, onFinish, onError } = lifecycle ?? {};
|
|
314
361
|
const startTime = Date.now();
|
|
315
362
|
const originalStream = result.stream;
|
|
316
363
|
// Lifecycle callbacks are awaited with a bounded deadline so callers
|
|
@@ -334,6 +381,9 @@ export class BaseProvider {
|
|
|
334
381
|
logger.warn(`[lifecycle] ${label} callback error:`, e);
|
|
335
382
|
}
|
|
336
383
|
};
|
|
384
|
+
// Arrow, like `safeFire` above: the generator is a plain function expression, so
|
|
385
|
+
// `this` is not bound inside it.
|
|
386
|
+
const classifyStreamError = (e) => this.classifyStreamError(e);
|
|
337
387
|
const wrappedStream = (async function* () {
|
|
338
388
|
let accumulated = "";
|
|
339
389
|
let seq = 0;
|
|
@@ -382,7 +432,7 @@ export class BaseProvider {
|
|
|
382
432
|
recoverable: false,
|
|
383
433
|
}), "onError");
|
|
384
434
|
}
|
|
385
|
-
throw err;
|
|
435
|
+
throw classifyStreamError(err);
|
|
386
436
|
}
|
|
387
437
|
})();
|
|
388
438
|
return { ...result, stream: wrappedStream };
|
|
@@ -437,6 +487,61 @@ export class BaseProvider {
|
|
|
437
487
|
logger.warn("[lifecycle] onError callback error:", e);
|
|
438
488
|
}
|
|
439
489
|
}
|
|
490
|
+
/**
|
|
491
|
+
* Build the fake-stream output and apply the same incremental TTS wrapper
|
|
492
|
+
* used by the standard NeuroLink stream path.
|
|
493
|
+
*/
|
|
494
|
+
createFakeStreamingOutput(result, options, onTTSComplete) {
|
|
495
|
+
const incrementalTTS = options.tts?.enabled === true;
|
|
496
|
+
const source = (async function* () {
|
|
497
|
+
if (result?.content) {
|
|
498
|
+
const words = result.content.split(/(\s+)/);
|
|
499
|
+
let buffer = "";
|
|
500
|
+
for (let i = 0; i < words.length; i++) {
|
|
501
|
+
buffer += words[i];
|
|
502
|
+
const shouldYield = i === words.length - 1 ||
|
|
503
|
+
buffer.length > 50 ||
|
|
504
|
+
/[.!?;,]\s*$/.test(buffer);
|
|
505
|
+
if (shouldYield && buffer.trim()) {
|
|
506
|
+
yield { content: buffer };
|
|
507
|
+
buffer = "";
|
|
508
|
+
await new Promise((resolve) => {
|
|
509
|
+
setTimeout(resolve, Math.random() * 9 + 1);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (buffer.trim()) {
|
|
514
|
+
yield { content: buffer };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (result?.imageOutput) {
|
|
518
|
+
yield { type: "image", imageOutput: result.imageOutput };
|
|
519
|
+
}
|
|
520
|
+
if (result?.audio && !incrementalTTS) {
|
|
521
|
+
yield {
|
|
522
|
+
type: "tts_audio",
|
|
523
|
+
audio: {
|
|
524
|
+
data: result.audio.buffer,
|
|
525
|
+
format: result.audio.format,
|
|
526
|
+
index: 0,
|
|
527
|
+
isFinal: true,
|
|
528
|
+
cumulativeSize: result.audio.size,
|
|
529
|
+
voice: result.audio.voice,
|
|
530
|
+
sampleRate: result.audio.sampleRate,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
if (!incrementalTTS || !options.tts) {
|
|
536
|
+
return source;
|
|
537
|
+
}
|
|
538
|
+
return interleaveTTSStream({
|
|
539
|
+
stream: source,
|
|
540
|
+
provider: options.tts.provider ?? options.provider ?? this.providerName,
|
|
541
|
+
options: options.tts,
|
|
542
|
+
onComplete: onTTSComplete,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
440
545
|
/**
|
|
441
546
|
* Execute fake streaming - extracted method for reusability
|
|
442
547
|
*/
|
|
@@ -476,10 +581,10 @@ export class BaseProvider {
|
|
|
476
581
|
skipToolPromptInjection: options.skipToolPromptInjection,
|
|
477
582
|
timeout: options.timeout,
|
|
478
583
|
stt: options.stt,
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
tts: options.tts,
|
|
584
|
+
// Streaming TTS is synthesized incrementally by
|
|
585
|
+
// createFakeStreamingOutput; do not let generate() perform a duplicate
|
|
586
|
+
// input- or whole-response synthesis first.
|
|
587
|
+
tts: options.tts?.enabled ? undefined : options.tts,
|
|
483
588
|
};
|
|
484
589
|
logger.debug(`Calling generate for fake streaming`, {
|
|
485
590
|
provider: this.providerName,
|
|
@@ -496,58 +601,45 @@ export class BaseProvider {
|
|
|
496
601
|
hasImageOutput: !!result?.imageOutput,
|
|
497
602
|
timestamp: Date.now(),
|
|
498
603
|
});
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
604
|
+
const incrementalTTS = options.tts?.enabled === true;
|
|
605
|
+
const ttsProvider = options.tts?.provider ?? options.provider ?? this.providerName;
|
|
606
|
+
const ttsStartedAt = Date.now();
|
|
607
|
+
let resolveAudio;
|
|
608
|
+
let audioSettled = false;
|
|
609
|
+
const audio = incrementalTTS
|
|
610
|
+
? new Promise((resolve) => {
|
|
611
|
+
resolveAudio = resolve;
|
|
612
|
+
}).catch(() => undefined)
|
|
613
|
+
: undefined;
|
|
614
|
+
const ttsMetadata = incrementalTTS
|
|
615
|
+
? {
|
|
616
|
+
attempted: TTSProcessor.supports(ttsProvider),
|
|
617
|
+
success: false,
|
|
618
|
+
}
|
|
619
|
+
: result?.ttsMetadata;
|
|
620
|
+
const onTTSComplete = incrementalTTS
|
|
621
|
+
? (ttsResult, error) => {
|
|
622
|
+
if (audioSettled) {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
audioSettled = true;
|
|
626
|
+
if (ttsMetadata) {
|
|
627
|
+
ttsMetadata.success =
|
|
628
|
+
error === undefined && ttsResult !== undefined;
|
|
629
|
+
if (error) {
|
|
630
|
+
ttsMetadata.error = error;
|
|
520
631
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
yield { content: buffer };
|
|
632
|
+
else {
|
|
633
|
+
delete ttsMetadata.error;
|
|
524
634
|
}
|
|
635
|
+
ttsMetadata.latency = Date.now() - ttsStartedAt;
|
|
525
636
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
533
|
-
// Yield synthesized audio so callers using stream() with tts.enabled
|
|
534
|
-
// still receive a tts_audio chunk on the fake-streaming fallback
|
|
535
|
-
// path (matches the discriminator used by the real streaming path).
|
|
536
|
-
if (result?.audio) {
|
|
537
|
-
yield {
|
|
538
|
-
type: "tts_audio",
|
|
539
|
-
audio: {
|
|
540
|
-
data: result.audio.buffer,
|
|
541
|
-
format: result.audio.format,
|
|
542
|
-
index: 0,
|
|
543
|
-
isFinal: true,
|
|
544
|
-
cumulativeSize: result.audio.size,
|
|
545
|
-
voice: result.audio.voice,
|
|
546
|
-
sampleRate: result.audio.sampleRate,
|
|
547
|
-
},
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
})(),
|
|
637
|
+
resolveAudio?.(ttsResult);
|
|
638
|
+
}
|
|
639
|
+
: undefined;
|
|
640
|
+
// Create a synthetic stream from the generate result that simulates progressive delivery
|
|
641
|
+
return {
|
|
642
|
+
stream: this.createFakeStreamingOutput(result, options, onTTSComplete),
|
|
551
643
|
usage: result?.usage,
|
|
552
644
|
provider: result?.provider,
|
|
553
645
|
model: result?.model,
|
|
@@ -569,6 +661,8 @@ export class BaseProvider {
|
|
|
569
661
|
// 🔧 FIX: Include analytics and evaluation from generate result
|
|
570
662
|
analytics: result?.analytics,
|
|
571
663
|
evaluation: result?.evaluation,
|
|
664
|
+
audio,
|
|
665
|
+
ttsMetadata,
|
|
572
666
|
};
|
|
573
667
|
}
|
|
574
668
|
catch (error) {
|
|
@@ -1573,6 +1667,65 @@ export class BaseProvider {
|
|
|
1573
1667
|
* original identity so that isAbortError() can detect them in
|
|
1574
1668
|
* retry/fallback loops (directProviderGeneration, performMCPGenerationRetries).
|
|
1575
1669
|
*/
|
|
1670
|
+
/**
|
|
1671
|
+
* Classify an error that escaped while the consumer was ITERATING a stream.
|
|
1672
|
+
*
|
|
1673
|
+
* `stream()` only awaits the CONSTRUCTION of the provider's stream object, and a provider
|
|
1674
|
+
* that discovers its failure lazily throws on first pull instead — so the raw upstream
|
|
1675
|
+
* error reached the consumer with no provider tag and no classification, while the same
|
|
1676
|
+
* failure through `generate()` was classified normally. Measured on OpenAI:
|
|
1677
|
+
*
|
|
1678
|
+
* streaming "You have no credits remaining."
|
|
1679
|
+
* non-streaming "[openai] OpenAI quota exhausted — this will not resolve by retrying..."
|
|
1680
|
+
*
|
|
1681
|
+
* Three guards. Only the third is load-bearing against today's code; the first two are
|
|
1682
|
+
* deliberate depth against a hazard that is real but not currently reachable. Measured,
|
|
1683
|
+
* rather than assumed — see the note after the list.
|
|
1684
|
+
*
|
|
1685
|
+
* 1. ALREADY STAMPED — everything that went through `handleProviderError` carries the mark,
|
|
1686
|
+
* including the two formatters whose results escape the ProviderError hierarchy
|
|
1687
|
+
* (`amazonSagemaker` returns SageMakerError, `replicate` returns NeuroLinkError; both
|
|
1688
|
+
* extend Error directly). Without this they would be classified a second time and
|
|
1689
|
+
* degraded.
|
|
1690
|
+
* 2. ALREADY A ProviderError — covers providers that call `formatProviderError` DIRECTLY,
|
|
1691
|
+
* bypassing `handleProviderError` and therefore the stamp. Anthropic does this in its
|
|
1692
|
+
* own streaming catch (`anthropic/client.ts`), and its result is a ProviderError.
|
|
1693
|
+
* 3. HAS AN HTTP STATUS — without this, an ordinary bug is relabelled as a provider
|
|
1694
|
+
* failure. `classifyProviderError` ends in an unconditional catch-all
|
|
1695
|
+
* (`utils/errorClassifier.ts`: `if (!rule) return new ProviderError(...)`) that is not
|
|
1696
|
+
* gated on the error having come off the wire. Measured with the guard absent:
|
|
1697
|
+
* TypeError "Cannot read properties of undefined (reading 'content')"
|
|
1698
|
+
* became ProviderError "[openai] openai error: Cannot read properties of undefined..."
|
|
1699
|
+
* which would hide a real defect behind a plausible provider message.
|
|
1700
|
+
*
|
|
1701
|
+
* WHY 1 AND 2 ARE NOT CURRENTLY REACHABLE, and why they stay anyway. A statusCode is
|
|
1702
|
+
* attached to a formatted error in exactly one place — `handleProviderError` below — and
|
|
1703
|
+
* that same method applies the stamp. So a ProviderError carrying a status is always
|
|
1704
|
+
* stamped (caught by guard 1's own condition), and a ProviderError produced by a direct
|
|
1705
|
+
* `formatProviderError` call carries no status, so guard 3 already returns it untouched.
|
|
1706
|
+
* Verified by disabling guards 1 and 2 together, rebuilding, and re-running: the mocked
|
|
1707
|
+
* provider contract suite stayed 64/64 and a mocked Anthropic streaming 429 was
|
|
1708
|
+
* byte-identical (`RateLimitError`, one `[anthropic]` prefix).
|
|
1709
|
+
*
|
|
1710
|
+
* They remain because the hazard they cover is measured and real: `handleProviderError`
|
|
1711
|
+
* is NOT idempotent — it copies statusCode onto its own output, so a second pass
|
|
1712
|
+
* re-matches the bare 429 rule and DEGRADES the classification:
|
|
1713
|
+
* pass 1 ProviderError "...quota exhausted — this will not resolve by retrying..."
|
|
1714
|
+
* pass 2 RateLimitError "...rate limit exceeded..."
|
|
1715
|
+
* The day any provider attaches a status to an error it formats itself, guard 3 stops
|
|
1716
|
+
* covering that case and this degradation becomes live. Cheap insurance, not dead code —
|
|
1717
|
+
* but do not cite guards 1 and 2 as proven-by-failure the way guard 3 is.
|
|
1718
|
+
*/
|
|
1719
|
+
classifyStreamError(error) {
|
|
1720
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1721
|
+
if (isProviderErrorClassified(err) || err instanceof ProviderError) {
|
|
1722
|
+
return err;
|
|
1723
|
+
}
|
|
1724
|
+
if (duckTypedStatusCode(err) === undefined) {
|
|
1725
|
+
return err;
|
|
1726
|
+
}
|
|
1727
|
+
return this.handleProviderError(err);
|
|
1728
|
+
}
|
|
1576
1729
|
handleProviderError(error) {
|
|
1577
1730
|
if (isAbortError(error)) {
|
|
1578
1731
|
// Preserve AbortError identity — never wrap in provider-specific formatting
|
|
@@ -1644,6 +1797,10 @@ export class BaseProvider {
|
|
|
1644
1797
|
catch {
|
|
1645
1798
|
// Non-blocking — telemetry failures shouldn't mask the original error
|
|
1646
1799
|
}
|
|
1800
|
+
// Stamp AFTER formatting so any later catch site can tell this error has
|
|
1801
|
+
// already been classified. Deliberately not applied to the AbortError
|
|
1802
|
+
// passthrough above — that returns the original error untouched.
|
|
1803
|
+
markProviderErrorClassified(formatted);
|
|
1647
1804
|
return formatted;
|
|
1648
1805
|
}
|
|
1649
1806
|
/**
|
|
@@ -27,8 +27,8 @@ import { readdir, stat } from "fs/promises";
|
|
|
27
27
|
import { homedir } from "os";
|
|
28
28
|
import { join } from "path";
|
|
29
29
|
import { calculateCost, hasPricing } from "../utils/pricing.js";
|
|
30
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
30
31
|
const CLI_ID = "claude-code";
|
|
31
|
-
const DEFAULT_SINCE_DAYS = 30;
|
|
32
32
|
const PROVIDER = "anthropic";
|
|
33
33
|
function projectsRoot() {
|
|
34
34
|
return join(homedir(), ".claude", "projects");
|
|
@@ -190,11 +190,7 @@ export async function createClaudeCodeReader() {
|
|
|
190
190
|
// 32.8s — the same unbounded sweep this guard exists to prevent, reached
|
|
191
191
|
// by a different door. The old guard caught it with Number.isFinite and
|
|
192
192
|
// the replacement dropped that check.
|
|
193
|
-
const
|
|
194
|
-
const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
|
|
195
|
-
const cutoff = sinceDays === Infinity
|
|
196
|
-
? undefined
|
|
197
|
-
: Date.now() - Math.max(0, sinceDays) * 86_400_000;
|
|
193
|
+
const cutoff = resolveScanCutoffMs(options?.sinceDays);
|
|
198
194
|
let filesScanned = 0;
|
|
199
195
|
for (const file of files) {
|
|
200
196
|
try {
|
|
@@ -31,8 +31,8 @@ import { createInterface } from "readline";
|
|
|
31
31
|
import { readdir, stat } from "fs/promises";
|
|
32
32
|
import { homedir } from "os";
|
|
33
33
|
import { join } from "path";
|
|
34
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
34
35
|
const CLI_ID = "codex";
|
|
35
|
-
const DEFAULT_SINCE_DAYS = 30;
|
|
36
36
|
function sessionsRoot() {
|
|
37
37
|
return join(homedir(), ".codex", "sessions");
|
|
38
38
|
}
|
|
@@ -178,11 +178,7 @@ export async function createCodexReader() {
|
|
|
178
178
|
// 32.8s — the same unbounded sweep this guard exists to prevent, reached
|
|
179
179
|
// by a different door. The old guard caught it with Number.isFinite and
|
|
180
180
|
// the replacement dropped that check.
|
|
181
|
-
const
|
|
182
|
-
const sinceDays = Number.isNaN(requestedDays) ? 0 : requestedDays;
|
|
183
|
-
const cutoff = sinceDays === Infinity
|
|
184
|
-
? undefined
|
|
185
|
-
: Date.now() - Math.max(0, sinceDays) * 86_400_000;
|
|
181
|
+
const cutoff = resolveScanCutoffMs(options?.sinceDays);
|
|
186
182
|
let filesScanned = 0;
|
|
187
183
|
for (const file of files) {
|
|
188
184
|
try {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
import { stat } from "fs/promises";
|
|
36
36
|
import { homedir } from "os";
|
|
37
37
|
import { join } from "path";
|
|
38
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
38
39
|
const CLI_ID = "opencode";
|
|
39
|
-
const DEFAULT_SINCE_DAYS = 30;
|
|
40
40
|
function databasePath() {
|
|
41
41
|
return join(homedir(), ".local", "share", "opencode", "opencode.db");
|
|
42
42
|
}
|
|
@@ -131,11 +131,9 @@ export async function createOpenCodeReader() {
|
|
|
131
131
|
// "no such column: NaN" — so the whole scan failed rather than
|
|
132
132
|
// over-reading. The cutoff is a bound parameter now, so a value can
|
|
133
133
|
// never be SQL syntax whatever it is.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const cutoffMs = sinceDays
|
|
137
|
-
? 0
|
|
138
|
-
: Date.now() - Math.max(0, sinceDays) * 86_400_000;
|
|
134
|
+
// `?? 0` because this value becomes a SQL parameter: an unbounded scan
|
|
135
|
+
// is expressed as "since the epoch", never as a non-finite number.
|
|
136
|
+
const cutoffMs = resolveScanCutoffMs(options?.sinceDays) ?? 0;
|
|
139
137
|
let db;
|
|
140
138
|
try {
|
|
141
139
|
// Read-only: this is the user's live store and OpenCode may be running.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "how far back does a local-usage scan reach".
|
|
3
|
+
*
|
|
4
|
+
* This calculation lived in three copies — claudeCodeReader, codexReader and
|
|
5
|
+
* openCodeReader — and has been wrong three separate times, each a different
|
|
6
|
+
* door into the same failure: an unbounded sweep of every transcript on the
|
|
7
|
+
* machine in answer to the narrowest possible request.
|
|
8
|
+
*
|
|
9
|
+
* sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
|
|
10
|
+
* sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
|
|
11
|
+
* against NaN is false, so the filter passed
|
|
12
|
+
* everything 17,537 files, 32.8s
|
|
13
|
+
* sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
|
|
14
|
+
* cutoff became -Infinity and every file passed
|
|
15
|
+
*
|
|
16
|
+
* Three fixes in three files each closed one door and left the others open.
|
|
17
|
+
* The logic lives here once now, so the next door closes everywhere at once.
|
|
18
|
+
*
|
|
19
|
+
* Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
|
|
20
|
+
* not a usable finite window — NaN, negative, or so large the arithmetic stops
|
|
21
|
+
* being finite — collapses to a zero-length window, which is what a
|
|
22
|
+
* nonsensical request should read as.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DEFAULT_SINCE_DAYS = 30;
|
|
25
|
+
/**
|
|
26
|
+
* @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
|
|
27
|
+
* unbounded (all-history) scan — which only `Infinity` produces.
|
|
28
|
+
*/
|
|
29
|
+
export declare function resolveScanCutoffMs(sinceDays: number | undefined, defaultDays?: number): number | undefined;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "how far back does a local-usage scan reach".
|
|
3
|
+
*
|
|
4
|
+
* This calculation lived in three copies — claudeCodeReader, codexReader and
|
|
5
|
+
* openCodeReader — and has been wrong three separate times, each a different
|
|
6
|
+
* door into the same failure: an unbounded sweep of every transcript on the
|
|
7
|
+
* machine in answer to the narrowest possible request.
|
|
8
|
+
*
|
|
9
|
+
* sinceDays: 0 left the cutoff undefined 17,534 files, 35.9s
|
|
10
|
+
* sinceDays: NaN Math.max(0, NaN) is NaN, and every comparison
|
|
11
|
+
* against NaN is false, so the filter passed
|
|
12
|
+
* everything 17,537 files, 32.8s
|
|
13
|
+
* sinceDays: MAX_VALUE MAX_VALUE * 86_400_000 overflows to Infinity, so the
|
|
14
|
+
* cutoff became -Infinity and every file passed
|
|
15
|
+
*
|
|
16
|
+
* Three fixes in three files each closed one door and left the others open.
|
|
17
|
+
* The logic lives here once now, so the next door closes everywhere at once.
|
|
18
|
+
*
|
|
19
|
+
* Contract: `Infinity` is the ONLY value meaning all-history. Anything that is
|
|
20
|
+
* not a usable finite window — NaN, negative, or so large the arithmetic stops
|
|
21
|
+
* being finite — collapses to a zero-length window, which is what a
|
|
22
|
+
* nonsensical request should read as.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_SINCE_DAYS = 30;
|
|
25
|
+
const MS_PER_DAY = 86_400_000;
|
|
26
|
+
/**
|
|
27
|
+
* @returns the epoch-ms cutoff a scan must not read past, or `undefined` for an
|
|
28
|
+
* unbounded (all-history) scan — which only `Infinity` produces.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveScanCutoffMs(sinceDays, defaultDays = DEFAULT_SINCE_DAYS) {
|
|
31
|
+
const requested = sinceDays ?? defaultDays;
|
|
32
|
+
const days = Number.isNaN(requested) ? 0 : requested;
|
|
33
|
+
if (days === Infinity) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const span = Math.max(0, days) * MS_PER_DAY;
|
|
37
|
+
// A finite `days` can still produce a non-finite span: Number.MAX_VALUE and
|
|
38
|
+
// anything near it overflow on the multiply. Treat that as no window rather
|
|
39
|
+
// than letting `Date.now() - Infinity` become -Infinity, which every
|
|
40
|
+
// timestamp compares as newer than.
|
|
41
|
+
return Date.now() - (Number.isFinite(span) ? span : 0);
|
|
42
|
+
}
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -945,7 +945,9 @@ export declare class NeuroLink {
|
|
|
945
945
|
*
|
|
946
946
|
* // Consume the stream
|
|
947
947
|
* for await (const chunk of result.stream) {
|
|
948
|
-
*
|
|
948
|
+
* if ("content" in chunk) {
|
|
949
|
+
* process.stdout.write(chunk.content);
|
|
950
|
+
* }
|
|
949
951
|
* }
|
|
950
952
|
*
|
|
951
953
|
* // Advanced streaming with options
|
|
@@ -1031,25 +1033,10 @@ export declare class NeuroLink {
|
|
|
1031
1033
|
private validateStreamRequestOptions;
|
|
1032
1034
|
private maybeHandleWorkflowStreamRequest;
|
|
1033
1035
|
private runStandardStreamRequest;
|
|
1034
|
-
/**
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
* stays under the max-lines-per-function lint budget. Behaviour preserved
|
|
1039
|
-
* exactly:
|
|
1040
|
-
* - When Mode 2 is enabled (`tts.enabled && tts.useAiResponse`) AND the
|
|
1041
|
-
* model produced non-empty content: synthesises one final audio buffer
|
|
1042
|
-
* and returns it as an `audioChunk` for the caller to `yield`. Resolves
|
|
1043
|
-
* `ttsResolver` with the `TTSResult`.
|
|
1044
|
-
* - When Mode 2 is enabled but synthesis fails: logs a warning and resolves
|
|
1045
|
-
* `ttsResolver` with `undefined`.
|
|
1046
|
-
* - When Mode 2 is requested but skipped (empty content / wrong mode):
|
|
1047
|
-
* resolves `ttsResolver` with `undefined` early so callers awaiting
|
|
1048
|
-
* `result.audio` unblock before the surrounding `finally` cleanup
|
|
1049
|
-
* completes (Issue 7 latency micro-opt — the finally block also resolves
|
|
1050
|
-
* defensively, so this is a redundant early signal, not a coverage fix).
|
|
1051
|
-
*/
|
|
1052
|
-
private synthesizeStreamModeTwo;
|
|
1036
|
+
/** Wrap one provider stream with incremental TTS synthesis. */
|
|
1037
|
+
private createIncrementalTTSStream;
|
|
1038
|
+
/** Prevent provider fallback streams from duplicating outer streaming TTS. */
|
|
1039
|
+
private deferProviderStreamTTS;
|
|
1053
1040
|
/**
|
|
1054
1041
|
* Prepare stream options: initialize memory, MCP, retrieval, orchestration,
|
|
1055
1042
|
* Ollama tool auto-disable, factory processing, and tool detection.
|