@juspay/neurolink 12.28.4 → 12.29.1
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 +2 -2
- package/dist/adapters/tts/googleTTSHandler.d.ts +71 -1
- package/dist/adapters/tts/googleTTSHandler.js +254 -0
- package/dist/browser/neurolink.min.js +400 -400
- package/dist/cli/commands/voices.d.ts +23 -0
- package/dist/cli/commands/voices.js +102 -0
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/cli/parser.js +3 -0
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +15 -0
- package/dist/core/baseProvider.d.ts +4 -0
- package/dist/core/baseProvider.js +245 -148
- package/dist/core/modules/structuredOutputPolicy.d.ts +27 -0
- package/dist/core/modules/structuredOutputPolicy.js +36 -0
- package/dist/core/nativeGenerateLoop.js +7 -1
- package/dist/neurolink.d.ts +42 -0
- package/dist/neurolink.js +102 -2
- package/dist/providers/catalog/index.generated.d.ts +1 -1
- package/dist/providers/catalog/index.generated.js +6 -0
- package/dist/providers/catalog/morph.json +97 -0
- package/dist/providers/catalog/novita.json +114 -0
- package/dist/providers/openaiChatCompletionsBase.d.ts +22 -0
- package/dist/providers/openaiChatCompletionsBase.js +291 -8
- package/dist/server/routes/claudeProxyRoutes.js +12 -1
- package/dist/server/utils/validation.d.ts +34 -0
- package/dist/server/utils/validation.js +27 -0
- package/dist/server/websocket/WebSocketHandler.d.ts +4 -6
- package/dist/server/websocket/WebSocketHandler.js +185 -18
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/generate.d.ts +15 -0
- package/dist/types/isolatedAgent.d.ts +16 -7
- package/dist/types/openaiCompatible.d.ts +6 -0
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +50 -3
- package/dist/types/server.d.ts +8 -0
- package/dist/types/stream.d.ts +30 -0
- package/dist/types/tts.d.ts +10 -0
- package/dist/types/utilities.d.ts +16 -3
- package/dist/utils/logger.d.ts +58 -13
- package/dist/utils/logger.js +156 -11
- package/dist/utils/providerHealth.d.ts +65 -1
- package/dist/utils/providerHealth.js +216 -54
- package/dist/utils/ttsProcessor.d.ts +30 -1
- package/dist/utils/ttsProcessor.js +76 -0
- package/docs-site/static/search-index.json +4 -4
- package/package.json +6 -1
|
@@ -30,7 +30,7 @@ import { logger } from "../utils/logger.js";
|
|
|
30
30
|
import { interleaveTTSStream } from "../utils/ttsStream.js";
|
|
31
31
|
import { attachStreamCancel, cancelStream, releaseIterator, } from "../utils/streamCancellation.js";
|
|
32
32
|
import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
|
|
33
|
-
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
33
|
+
import { composeAbortSignals, composeAbortSignalsScoped, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
34
34
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
35
35
|
import { getKeyCount, getKeysAsString, transformToolExecutions, } from "../utils/transformationUtils.js";
|
|
36
36
|
import { ToolExecutionRecorder } from "./toolExecutionRecorder.js";
|
|
@@ -228,155 +228,195 @@ export class BaseProvider {
|
|
|
228
228
|
await this.ensureModelLimits();
|
|
229
229
|
let options = this.normalizeStreamOptions(optionsOrPrompt);
|
|
230
230
|
this.applyFileToolRoots(options);
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// the
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// consumer-supplied onError before re-throwing through
|
|
266
|
-
// handleProviderError(), so we do not need to wrap again here —
|
|
267
|
-
// doing so would route the error through handleProviderError()
|
|
268
|
-
// twice (and risk a double-fire onError without the shared
|
|
269
|
-
// lifecycle-fired WeakSet mark).
|
|
270
|
-
const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
|
|
271
|
-
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
// CRITICAL: Image generation models don't support real streaming
|
|
275
|
-
// Force fake streaming for image models to ensure image output is yielded.
|
|
276
|
-
// resolveRequestKind() skips this path when the caller explicitly requests
|
|
277
|
-
// non-image output (e.g. JSON analysis) so dual-mode models like
|
|
278
|
-
// gemini-3.1-flash-image-preview can still perform text/structured
|
|
279
|
-
// generation — see its doc comment for the full precedence table.
|
|
280
|
-
const requestKind = resolveRequestKind(options, this.modelName);
|
|
281
|
-
if (requestKind === "image") {
|
|
282
|
-
logger.info(`Image model detected, forcing fake streaming`, {
|
|
283
|
-
provider: this.providerName,
|
|
284
|
-
model: this.modelName,
|
|
285
|
-
reason: "Image generation requires fake streaming to yield image output",
|
|
286
|
-
});
|
|
287
|
-
// Skip real streaming, go directly to fake streaming.
|
|
288
|
-
// executeFakeStreaming() owns its own catch + lifecycle fire, so
|
|
289
|
-
// wrapping again here would double-route through handleProviderError().
|
|
290
|
-
const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
|
|
291
|
-
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options);
|
|
292
|
-
}
|
|
293
|
-
// Central tool merge: Pre-merge base tools (MCP/built-in) with user-provided
|
|
294
|
-
// tools (e.g. RAG tools) into options.tools. This way, every provider's
|
|
295
|
-
// executeStream() can simply use options.tools (or getAllTools() + options.tools)
|
|
296
|
-
// and get the complete tool set without needing per-provider merge logic.
|
|
297
|
-
if (this.shouldUseTools(options, true)) {
|
|
298
|
-
const mergedTools = await this.getToolsForStream(options);
|
|
299
|
-
options = { ...options, tools: mergedTools };
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
options = { ...options, tools: {} };
|
|
303
|
-
}
|
|
304
|
-
// CRITICAL FIX: Always prefer real streaming over fake streaming
|
|
305
|
-
// Try real streaming first, use fake streaming only as fallback
|
|
231
|
+
// Own an abort signal for this call so teardown can reach the transport.
|
|
232
|
+
//
|
|
233
|
+
// #1550 gave teardown a way to close the iterator chain, but closing an
|
|
234
|
+
// iterator does not cancel the HTTP request underneath it — the provider
|
|
235
|
+
// read stays pending and the connection stays open. Providers already
|
|
236
|
+
// honour `options.abortSignal` and pass it to their transport; nothing
|
|
237
|
+
// ever fired one on abandonment, because the only signal available was
|
|
238
|
+
// the caller's and the caller had simply walked away.
|
|
239
|
+
//
|
|
240
|
+
// Composed rather than substituted, so a caller's own signal keeps
|
|
241
|
+
// working exactly as before and either source can end the request.
|
|
242
|
+
// Scoped, not plain `composeAbortSignals`: that one is `AbortSignal.any`,
|
|
243
|
+
// whose registration on a source survives until the DERIVED signal is
|
|
244
|
+
// collected. A caller that reuses one long-lived `abortSignal` across many
|
|
245
|
+
// stream calls would accumulate a dependent per call on it, released only
|
|
246
|
+
// by GC — the exact hazard composeAbortSignalsScoped was written for (see
|
|
247
|
+
// its docstring: MaxListenersExceededWarning at 10+ compositions). It
|
|
248
|
+
// registers removable listeners and hands back a `dispose()`, which
|
|
249
|
+
// `teardown()` calls when the stream settles.
|
|
250
|
+
const teardownController = new AbortController();
|
|
251
|
+
const { signal: composedStreamSignal, dispose: disposeComposedSignal } = composeAbortSignalsScoped(options.abortSignal, teardownController.signal);
|
|
252
|
+
if (composedStreamSignal !== options.abortSignal) {
|
|
253
|
+
options = { ...options, abortSignal: composedStreamSignal };
|
|
254
|
+
}
|
|
255
|
+
// Ownership of the composed signal transfers to the returned wrapper,
|
|
256
|
+
// whose teardown() disposes it. Until that wrapper exists, this method
|
|
257
|
+
// owns it — and every throw between here and there (an execution-control
|
|
258
|
+
// rejection, a provider error with no tool fallback) would otherwise
|
|
259
|
+
// leave composeAbortSignalsScoped's once-listeners attached to a caller
|
|
260
|
+
// signal that may outlive this call by many more.
|
|
261
|
+
//
|
|
262
|
+
// catch, deliberately, not finally: dispose() only detaches listeners, so
|
|
263
|
+
// running it on the success path would sever the caller's abort from the
|
|
264
|
+
// composed signal the stream is still using.
|
|
306
265
|
try {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
266
|
+
// Before anything else, and before a single byte leaves the process: an
|
|
267
|
+
// execution policy this provider cannot honour is an error, and a policy
|
|
268
|
+
// whose shape could be read two ways is an error. Both are silent bugs at
|
|
269
|
+
// the point they would otherwise matter.
|
|
270
|
+
validateExecutionControl(options.executionControl, this.providerName, this.supportsExecutionControl(), {
|
|
271
|
+
turnTimeoutMs: options.turnTimeoutMs,
|
|
272
|
+
toolTimeoutMs: options.toolTimeoutMs,
|
|
310
273
|
});
|
|
311
|
-
|
|
312
|
-
logger.info(`Real streaming succeeded`, {
|
|
274
|
+
logger.info(`Starting stream`, {
|
|
313
275
|
provider: this.providerName,
|
|
276
|
+
hasTools: !options.disableTools && this.supportsTools(),
|
|
277
|
+
disableTools: !!options.disableTools,
|
|
278
|
+
supportsTools: this.supportsTools(),
|
|
279
|
+
inputLength: options.input?.text?.length || 0,
|
|
280
|
+
maxTokens: options.maxTokens,
|
|
281
|
+
temperature: options.temperature,
|
|
314
282
|
timestamp: Date.now(),
|
|
315
283
|
});
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// unsupported" failures would change behaviour for every provider at
|
|
341
|
-
// once, so it needs its own characterization PR first; until then this
|
|
342
|
-
// comment records what the code does, not what a narrower design would.
|
|
343
|
-
const errMsg = realStreamError instanceof Error
|
|
344
|
-
? realStreamError.message
|
|
345
|
-
: String(realStreamError);
|
|
346
|
-
const errName = realStreamError instanceof Error ? realStreamError.name : "";
|
|
347
|
-
if (errName === "AbortError" ||
|
|
348
|
-
errMsg.includes("abort") ||
|
|
349
|
-
errMsg.includes("timeout") ||
|
|
350
|
-
errMsg.includes("401") ||
|
|
351
|
-
errMsg.includes("403") ||
|
|
352
|
-
errMsg.includes("quota") ||
|
|
353
|
-
errMsg.includes("rate limit") ||
|
|
354
|
-
errMsg.includes("authentication")) {
|
|
355
|
-
await this.fireLifecycleErrorCallback(options, realStreamError);
|
|
356
|
-
throw this.handleProviderError(realStreamError);
|
|
284
|
+
// ===== EARLY MULTIMODAL DETECTION =====
|
|
285
|
+
// #1259: audioFiles was missing here while videoFiles was present, so an
|
|
286
|
+
// audio-only stream skipped this branch entirely.
|
|
287
|
+
const hasFileInput = !!options.input?.files?.length ||
|
|
288
|
+
!!options.input?.videoFiles?.length ||
|
|
289
|
+
!!options.input?.audioFiles?.length;
|
|
290
|
+
if (hasFileInput) {
|
|
291
|
+
// ===== VIDEO ANALYSIS DETECTION =====
|
|
292
|
+
// Check if video frames are present and handle with fake streaming
|
|
293
|
+
const messages = await this.buildMessagesForStream(options);
|
|
294
|
+
if (hasVideoFrames(messages)) {
|
|
295
|
+
logger.info(`Video frames detected in stream, using fake streaming for video analysis`, {
|
|
296
|
+
provider: this.providerName,
|
|
297
|
+
model: this.modelName,
|
|
298
|
+
});
|
|
299
|
+
// Note: executeFakeStreaming() owns its own catch that fires the
|
|
300
|
+
// consumer-supplied onError before re-throwing through
|
|
301
|
+
// handleProviderError(), so we do not need to wrap again here —
|
|
302
|
+
// doing so would route the error through handleProviderError()
|
|
303
|
+
// twice (and risk a double-fire onError without the shared
|
|
304
|
+
// lifecycle-fired WeakSet mark).
|
|
305
|
+
const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
|
|
306
|
+
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
|
|
307
|
+
}
|
|
357
308
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
309
|
+
// CRITICAL: Image generation models don't support real streaming
|
|
310
|
+
// Force fake streaming for image models to ensure image output is yielded.
|
|
311
|
+
// resolveRequestKind() skips this path when the caller explicitly requests
|
|
312
|
+
// non-image output (e.g. JSON analysis) so dual-mode models like
|
|
313
|
+
// gemini-3.1-flash-image-preview can still perform text/structured
|
|
314
|
+
// generation — see its doc comment for the full precedence table.
|
|
315
|
+
const requestKind = resolveRequestKind(options, this.modelName);
|
|
316
|
+
if (requestKind === "image") {
|
|
317
|
+
logger.info(`Image model detected, forcing fake streaming`, {
|
|
318
|
+
provider: this.providerName,
|
|
319
|
+
model: this.modelName,
|
|
320
|
+
reason: "Image generation requires fake streaming to yield image output",
|
|
321
|
+
});
|
|
322
|
+
// Skip real streaming, go directly to fake streaming.
|
|
323
|
+
// executeFakeStreaming() owns its own catch + lifecycle fire, so
|
|
324
|
+
// wrapping again here would double-route through handleProviderError().
|
|
368
325
|
const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
|
|
369
|
-
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options);
|
|
326
|
+
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
|
|
327
|
+
}
|
|
328
|
+
// Central tool merge: Pre-merge base tools (MCP/built-in) with user-provided
|
|
329
|
+
// tools (e.g. RAG tools) into options.tools. This way, every provider's
|
|
330
|
+
// executeStream() can simply use options.tools (or getAllTools() + options.tools)
|
|
331
|
+
// and get the complete tool set without needing per-provider merge logic.
|
|
332
|
+
if (this.shouldUseTools(options, true)) {
|
|
333
|
+
const mergedTools = await this.getToolsForStream(options);
|
|
334
|
+
options = { ...options, tools: mergedTools };
|
|
370
335
|
}
|
|
371
336
|
else {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
337
|
+
options = { ...options, tools: {} };
|
|
338
|
+
}
|
|
339
|
+
// CRITICAL FIX: Always prefer real streaming over fake streaming
|
|
340
|
+
// Try real streaming first, use fake streaming only as fallback
|
|
341
|
+
try {
|
|
342
|
+
logger.debug(`Attempting real streaming`, {
|
|
343
|
+
provider: this.providerName,
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
});
|
|
346
|
+
const realStreamResult = await this.executeStream(options, analysisSchema);
|
|
347
|
+
logger.info(`Real streaming succeeded`, {
|
|
348
|
+
provider: this.providerName,
|
|
349
|
+
timestamp: Date.now(),
|
|
350
|
+
});
|
|
351
|
+
// Wire lifecycle callbacks (onChunk/onFinish/onError) on the user-
|
|
352
|
+
// facing StreamResult.stream. The AI-SDK lifecycle middleware only
|
|
353
|
+
// sees AI-SDK-internal chunks via streamText/wrapLanguageModel, so
|
|
354
|
+
// providers with custom HTTP streaming (Ollama, llama.cpp's /api,
|
|
355
|
+
// anything that doesn't go through streamText) bypass it. Wrapping
|
|
356
|
+
// here makes the callbacks fire for every provider, regardless of
|
|
357
|
+
// streaming implementation.
|
|
358
|
+
return this.wrapStreamWithLifecycleCallbacks(this.withStreamModelFallback(realStreamResult, options, analysisSchema), options, teardownController, disposeComposedSignal);
|
|
359
|
+
}
|
|
360
|
+
catch (realStreamError) {
|
|
361
|
+
// Retired-model fallback runs FIRST, before any lifecycle callback has
|
|
362
|
+
// fired and before a single chunk has reached the consumer: onChunk and
|
|
363
|
+
// onFinish are only wired on the success path above, and onError fires
|
|
364
|
+
// further down this same catch. That ordering is what makes retrying a
|
|
365
|
+
// stream safe at all — nothing observable has happened yet.
|
|
366
|
+
const recovered = await this.retryStreamWithFallbackModel(realStreamError, options, analysisSchema, teardownController, disposeComposedSignal);
|
|
367
|
+
if (recovered) {
|
|
368
|
+
return recovered;
|
|
369
|
+
}
|
|
370
|
+
// The fallback is BROAD, not narrow: only the terminal errors listed
|
|
371
|
+
// below (abort, timeout, 401/403, quota, rate limit, authentication)
|
|
372
|
+
// re-throw. Every other failure — including a genuine configuration or
|
|
373
|
+
// programming error — is masked as a degraded fake stream whenever
|
|
374
|
+
// tools are enabled. Narrowing this to "streaming with tools is
|
|
375
|
+
// unsupported" failures would change behaviour for every provider at
|
|
376
|
+
// once, so it needs its own characterization PR first; until then this
|
|
377
|
+
// comment records what the code does, not what a narrower design would.
|
|
378
|
+
const errMsg = realStreamError instanceof Error
|
|
379
|
+
? realStreamError.message
|
|
380
|
+
: String(realStreamError);
|
|
381
|
+
const errName = realStreamError instanceof Error ? realStreamError.name : "";
|
|
382
|
+
if (errName === "AbortError" ||
|
|
383
|
+
errMsg.includes("abort") ||
|
|
384
|
+
errMsg.includes("timeout") ||
|
|
385
|
+
errMsg.includes("401") ||
|
|
386
|
+
errMsg.includes("403") ||
|
|
387
|
+
errMsg.includes("quota") ||
|
|
388
|
+
errMsg.includes("rate limit") ||
|
|
389
|
+
errMsg.includes("authentication")) {
|
|
390
|
+
await this.fireLifecycleErrorCallback(options, realStreamError);
|
|
391
|
+
throw this.handleProviderError(realStreamError);
|
|
392
|
+
}
|
|
393
|
+
logger.warn(`Real streaming failed for ${this.providerName}, falling back to fake streaming:`, {
|
|
394
|
+
error: errMsg,
|
|
395
|
+
timestamp: Date.now(),
|
|
396
|
+
});
|
|
397
|
+
// Fallback to fake streaming only if real streaming fails AND tools
|
|
398
|
+
// are enabled. executeFakeStreaming() owns its own catch + lifecycle
|
|
399
|
+
// fire, so a fake-streaming failure here surfaces through that path
|
|
400
|
+
// without needing an outer wrap (which would double-route through
|
|
401
|
+
// handleProviderError()).
|
|
402
|
+
if (!options.disableTools && this.supportsTools()) {
|
|
403
|
+
const fakeResult = await this.executeFakeStreaming(options, analysisSchema);
|
|
404
|
+
return this.wrapStreamWithLifecycleCallbacks(fakeResult, options, teardownController, disposeComposedSignal);
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// If real streaming failed and no tools are enabled, fire onError
|
|
408
|
+
// before re-throwing so consumer-supplied callbacks see the failure.
|
|
409
|
+
await this.fireLifecycleErrorCallback(options, realStreamError);
|
|
410
|
+
// If real streaming failed and no tools are enabled, re-throw the original error
|
|
411
|
+
logger.error(`Real streaming failed for ${this.providerName}:`, realStreamError);
|
|
412
|
+
throw this.handleProviderError(realStreamError);
|
|
413
|
+
}
|
|
378
414
|
}
|
|
379
415
|
}
|
|
416
|
+
catch (streamSetupError) {
|
|
417
|
+
disposeComposedSignal();
|
|
418
|
+
throw streamSetupError;
|
|
419
|
+
}
|
|
380
420
|
}
|
|
381
421
|
/**
|
|
382
422
|
* Swap in a fallback model when a stream dies on a retired id before it has
|
|
@@ -548,7 +588,7 @@ export class BaseProvider {
|
|
|
548
588
|
* re-throw, the onError firing) is left exactly as it was, so behaviour
|
|
549
589
|
* changes only when a fallback actually succeeds.
|
|
550
590
|
*/
|
|
551
|
-
async retryStreamWithFallbackModel(error, options, analysisSchema) {
|
|
591
|
+
async retryStreamWithFallbackModel(error, options, analysisSchema, teardownController, disposeComposedSignal) {
|
|
552
592
|
if (options.disableInternalFallback === true) {
|
|
553
593
|
return undefined;
|
|
554
594
|
}
|
|
@@ -565,7 +605,7 @@ export class BaseProvider {
|
|
|
565
605
|
this.refreshHandlersForModel(candidate);
|
|
566
606
|
try {
|
|
567
607
|
const result = await this.executeStream(options, analysisSchema);
|
|
568
|
-
return this.wrapStreamWithLifecycleCallbacks(result, options);
|
|
608
|
+
return this.wrapStreamWithLifecycleCallbacks(result, options, teardownController, disposeComposedSignal);
|
|
569
609
|
}
|
|
570
610
|
catch {
|
|
571
611
|
// Any failure on a candidate — stale id or otherwise — just moves to
|
|
@@ -590,8 +630,17 @@ export class BaseProvider {
|
|
|
590
630
|
* over /api/chat, custom OpenAI-compatible servers, etc). Wrapping the
|
|
591
631
|
* user-facing stream here ensures the callbacks fire regardless of the
|
|
592
632
|
* underlying transport.
|
|
593
|
-
|
|
594
|
-
|
|
633
|
+
*
|
|
634
|
+
* `teardownController`, when supplied, is the call's own abort source (see
|
|
635
|
+
* `stream()`): the cancel hook fires it on abandonment so the transport's
|
|
636
|
+
* in-flight HTTP request is cancelled, not just this iterator chain.
|
|
637
|
+
*/
|
|
638
|
+
wrapStreamWithLifecycleCallbacks(result, options, teardownController,
|
|
639
|
+
// Releases the scoped composition in stream() that put this call's
|
|
640
|
+
// teardown signal alongside the caller's. Runs from teardown(), so the
|
|
641
|
+
// listeners come off the caller's (possibly long-lived) signal the
|
|
642
|
+
// moment this stream settles rather than whenever GC gets to it.
|
|
643
|
+
disposeComposedSignal) {
|
|
595
644
|
const lifecycle = getLifecycleMiddlewareConfig(options);
|
|
596
645
|
// No early return when there are no callbacks. This wrapper is the only point
|
|
597
646
|
// every provider's stream passes through unconditionally (the real-streaming path
|
|
@@ -636,6 +685,29 @@ export class BaseProvider {
|
|
|
636
685
|
const upstreamIterable = {
|
|
637
686
|
[Symbol.asyncIterator]: () => upstreamIterator,
|
|
638
687
|
};
|
|
688
|
+
// Set by teardown, read by the generator's catch. Distinguishes "the
|
|
689
|
+
// provider failed" from "we aborted the provider because the consumer
|
|
690
|
+
// abandoned this stream", which must not be reported as a failure.
|
|
691
|
+
let teardownRequested = false;
|
|
692
|
+
let teardownDone = false;
|
|
693
|
+
// Shared by both teardown entry points below (native `.return()` via the
|
|
694
|
+
// `finally`, and the `attachStreamCancel` hook for a consumer parked
|
|
695
|
+
// mid-`await` that a queued `.return()` cannot reach promptly). Guarded
|
|
696
|
+
// so whichever fires first does the work exactly once.
|
|
697
|
+
const teardown = () => {
|
|
698
|
+
if (teardownDone) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
teardownDone = true;
|
|
702
|
+
teardownRequested = true;
|
|
703
|
+
// Abort before closing the iterators. The iterator close releases our
|
|
704
|
+
// side of the chain; this releases the provider's — the in-flight HTTP
|
|
705
|
+
// request that a closed iterator leaves running.
|
|
706
|
+
teardownController?.abort();
|
|
707
|
+
disposeComposedSignal?.();
|
|
708
|
+
cancelStream(originalStream);
|
|
709
|
+
releaseIterator(upstreamIterator);
|
|
710
|
+
};
|
|
639
711
|
const wrappedStream = (async function* () {
|
|
640
712
|
let accumulated = "";
|
|
641
713
|
let seq = 0;
|
|
@@ -672,6 +744,14 @@ export class BaseProvider {
|
|
|
672
744
|
}
|
|
673
745
|
catch (error) {
|
|
674
746
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
747
|
+
if (teardownRequested) {
|
|
748
|
+
// Our own abort, from a consumer that already broke out of this
|
|
749
|
+
// stream. Firing onError would report cleanup as a provider failure,
|
|
750
|
+
// and rethrowing would surface an error nobody is left to catch —
|
|
751
|
+
// an unhandled rejection, which terminates the process. Ending the
|
|
752
|
+
// generator quietly is the correct outcome for an abandoned stream.
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
675
755
|
if (onError && !hasLifecycleErrorFired(err)) {
|
|
676
756
|
// Mark before firing so a higher layer that also routes through
|
|
677
757
|
// fireLifecycleErrorCallback (or its own lifecycle wrapper) with
|
|
@@ -686,16 +766,33 @@ export class BaseProvider {
|
|
|
686
766
|
}
|
|
687
767
|
throw classifyStreamError(err);
|
|
688
768
|
}
|
|
769
|
+
finally {
|
|
770
|
+
// Unconditional and safe: `teardown()` is idempotent (guarded by
|
|
771
|
+
// `teardownDone`), and `cancelStream`/`releaseIterator` are
|
|
772
|
+
// documented as no-ops on an already-finished stream/iterator, so
|
|
773
|
+
// calling it again after normal completion or a real provider error
|
|
774
|
+
// (both of which reach here too) costs nothing. The path that
|
|
775
|
+
// actually needs it is a consumer breaking out of `for await` while
|
|
776
|
+
// this generator is parked right after a `yield` — that unwinds
|
|
777
|
+
// straight here, bypassing both the rest of the `try` and the
|
|
778
|
+
// `catch` above, since a native `.return()` is a "return"
|
|
779
|
+
// completion, not a thrown error. Closing the iterator chain (done
|
|
780
|
+
// automatically by `for-await-of`'s own `IteratorClose` on
|
|
781
|
+
// `upstreamIterable`) does not cancel the HTTP request underneath
|
|
782
|
+
// it, so the transport is aborted here explicitly.
|
|
783
|
+
teardown();
|
|
784
|
+
}
|
|
689
785
|
})();
|
|
690
786
|
// A consumer that breaks out of the stream cannot reach this generator
|
|
691
787
|
// through `.return()` while it is parked awaiting the provider — that
|
|
692
788
|
// request queues behind the in-flight `next()`. The hook closes the
|
|
693
789
|
// upstream directly and forwards the request to any wrapper below, so
|
|
694
|
-
// abandoning a stream really does release the provider connection.
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
790
|
+
// abandoning a stream really does release the provider connection. This
|
|
791
|
+
// is the fallback path for that case; the common case (a consumer that
|
|
792
|
+
// breaks right after receiving a chunk, so this generator is parked at
|
|
793
|
+
// the `yield` rather than mid-`await`) is handled by the `finally`
|
|
794
|
+
// above, which reaches `.return()` synchronously.
|
|
795
|
+
attachStreamCancel(wrappedStream, teardown);
|
|
699
796
|
// See the comment in withStreamModelFallback above: this spread must not
|
|
700
797
|
// be allowed to freeze a provider's lazy toolsUsed/toolExecutions getters.
|
|
701
798
|
return preserveLiveStreamAccessors(result, {
|
|
@@ -68,6 +68,33 @@ export declare function isToolsSchemaConflictError(error: unknown): boolean;
|
|
|
68
68
|
* (same flow as isToolsSchemaConflictError).
|
|
69
69
|
*/
|
|
70
70
|
export declare function isSchemaComplexityError(error: unknown): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* True when a provider error indicates the MODEL (not the provider as a
|
|
73
|
+
* whole) rejects `response_format` outright, independent of which mode was
|
|
74
|
+
* requested. The catalog schema declares `structuredOutput` per provider,
|
|
75
|
+
* not per model (`catalogCapabilitiesSchema`, `src/lib/providers/catalog/
|
|
76
|
+
* schema.ts` — there is no per-model override the way `tools:
|
|
77
|
+
* "model-dependent"` has one), so a provider can accurately claim the
|
|
78
|
+
* capability for its default model while a caller-selected fallback model
|
|
79
|
+
* rejects every `response_format` value. Novita's
|
|
80
|
+
* `meta-llama/llama-3.3-70b-instruct` is the recorded case: both
|
|
81
|
+
* `json_schema` ("does not support 'json_schema' response format.
|
|
82
|
+
* Supported formats: json_object") and `json_object` ("response format
|
|
83
|
+
* json_object is not supported") come back as 400s on that model, while
|
|
84
|
+
* the catalog's default model accepts both.
|
|
85
|
+
*
|
|
86
|
+
* This is intentionally a text match on the classified error, not a
|
|
87
|
+
* per-model catalog flag: it lets ANY vendor's per-model rejection recover
|
|
88
|
+
* the same way, mirroring `isToolsSchemaConflictError` /
|
|
89
|
+
* `isSchemaComplexityError` above. Recovery here means the same thing it
|
|
90
|
+
* means for those two — drop `response_format` and spell the JSON Schema
|
|
91
|
+
* into the prompt instead — which is exactly what CLAUDE.md's Critical
|
|
92
|
+
* Rule 3 requires unconditionally ("Regardless of provider,
|
|
93
|
+
* generate({ schema }) is guaranteed to return valid JSON in content plus
|
|
94
|
+
* a parsed structuredData object"): a model-specific capability gap must
|
|
95
|
+
* degrade to best-effort JSON, not throw.
|
|
96
|
+
*/
|
|
97
|
+
export declare function isResponseFormatUnsupportedError(error: unknown): boolean;
|
|
71
98
|
/**
|
|
72
99
|
* True when a provider error indicates the request was rejected because the
|
|
73
100
|
* `temperature` parameter is deprecated / unsupported for the model. The newest
|
|
@@ -102,6 +102,42 @@ export function isSchemaComplexityError(error) {
|
|
|
102
102
|
: "";
|
|
103
103
|
return /too many states/i.test(message);
|
|
104
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* True when a provider error indicates the MODEL (not the provider as a
|
|
107
|
+
* whole) rejects `response_format` outright, independent of which mode was
|
|
108
|
+
* requested. The catalog schema declares `structuredOutput` per provider,
|
|
109
|
+
* not per model (`catalogCapabilitiesSchema`, `src/lib/providers/catalog/
|
|
110
|
+
* schema.ts` — there is no per-model override the way `tools:
|
|
111
|
+
* "model-dependent"` has one), so a provider can accurately claim the
|
|
112
|
+
* capability for its default model while a caller-selected fallback model
|
|
113
|
+
* rejects every `response_format` value. Novita's
|
|
114
|
+
* `meta-llama/llama-3.3-70b-instruct` is the recorded case: both
|
|
115
|
+
* `json_schema` ("does not support 'json_schema' response format.
|
|
116
|
+
* Supported formats: json_object") and `json_object` ("response format
|
|
117
|
+
* json_object is not supported") come back as 400s on that model, while
|
|
118
|
+
* the catalog's default model accepts both.
|
|
119
|
+
*
|
|
120
|
+
* This is intentionally a text match on the classified error, not a
|
|
121
|
+
* per-model catalog flag: it lets ANY vendor's per-model rejection recover
|
|
122
|
+
* the same way, mirroring `isToolsSchemaConflictError` /
|
|
123
|
+
* `isSchemaComplexityError` above. Recovery here means the same thing it
|
|
124
|
+
* means for those two — drop `response_format` and spell the JSON Schema
|
|
125
|
+
* into the prompt instead — which is exactly what CLAUDE.md's Critical
|
|
126
|
+
* Rule 3 requires unconditionally ("Regardless of provider,
|
|
127
|
+
* generate({ schema }) is guaranteed to return valid JSON in content plus
|
|
128
|
+
* a parsed structuredData object"): a model-specific capability gap must
|
|
129
|
+
* degrade to best-effort JSON, not throw.
|
|
130
|
+
*/
|
|
131
|
+
export function isResponseFormatUnsupportedError(error) {
|
|
132
|
+
const message = error instanceof Error
|
|
133
|
+
? error.message
|
|
134
|
+
: typeof error === "string"
|
|
135
|
+
? error
|
|
136
|
+
: "";
|
|
137
|
+
return (/does not support[^.]{0,80}response[\s_-]?format/i.test(message) ||
|
|
138
|
+
/response[\s_-]?format[^.]{0,60}(?:is )?not support/i.test(message) ||
|
|
139
|
+
/unsupported response[\s_-]?format/i.test(message));
|
|
140
|
+
}
|
|
105
141
|
/**
|
|
106
142
|
* True when a provider error indicates the request was rejected because the
|
|
107
143
|
* `temperature` parameter is deprecated / unsupported for the model. The newest
|
|
@@ -80,7 +80,13 @@ const validateToolInput = (tool, input) => {
|
|
|
80
80
|
* fallback would silently do nothing.
|
|
81
81
|
*/
|
|
82
82
|
export const appendJsonSchemaInstruction = (conversation, schema) => {
|
|
83
|
-
|
|
83
|
+
// "value", not "object": a ValidationSchema also accepts array and scalar
|
|
84
|
+
// roots (z.array(...), z.string()), and demanding an object told a
|
|
85
|
+
// compliant model to emit something those roots can never satisfy — so the
|
|
86
|
+
// fallback silently produced nothing for them however well the model
|
|
87
|
+
// complied. The generate path's own re-ask prompt was corrected for this
|
|
88
|
+
// reason; this shared helper, used by both paths, was missed.
|
|
89
|
+
const instruction = "When you give your final answer, respond with only a single JSON value " +
|
|
84
90
|
"that conforms to the following JSON Schema. No prose before or after it, " +
|
|
85
91
|
`and no markdown code fence. JSON Schema: ${JSON.stringify(schema)}`;
|
|
86
92
|
const lastSystemIndex = conversation.reduce((found, message, index) => (message.role === "system" ? index : found), -1);
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -66,6 +66,29 @@ export declare class NeuroLink {
|
|
|
66
66
|
private mcpSkipped;
|
|
67
67
|
private mcpInitPromise;
|
|
68
68
|
private emitter;
|
|
69
|
+
/**
|
|
70
|
+
* Process-unique id used to attribute this instance's log events. The SDK
|
|
71
|
+
* entry points run their bodies inside `logger.runInInstanceScope(this.logInstanceId, …)`,
|
|
72
|
+
* which is what lets a worker's log bridge receive only the worker's own
|
|
73
|
+
* events instead of everything in the process.
|
|
74
|
+
*/
|
|
75
|
+
private readonly logInstanceId;
|
|
76
|
+
/**
|
|
77
|
+
* Detach callbacks for scoped log bridges of workers this instance created
|
|
78
|
+
* via {@link createWorkerInstance} with `onLog`, that have not yet been
|
|
79
|
+
* removed by the worker's own `dispose()`. The scoped-emitter registry
|
|
80
|
+
* lives on the process-global logger, keyed by the WORKER's id — not this
|
|
81
|
+
* host's — so it survives this host being garbage collected. If a caller
|
|
82
|
+
* disposes the host but forgets an individual worker (the host is the
|
|
83
|
+
* long-lived object in most delegation patterns; the worker the
|
|
84
|
+
* short-lived one), the bridge — and everything its `onLog` closes over —
|
|
85
|
+
* would otherwise stay registered for the life of the process. `dispose()`
|
|
86
|
+
* sweeps whatever is still here so host cleanup is sufficient on its own,
|
|
87
|
+
* matching the pre-scoped-routing behavior where a worker's forward
|
|
88
|
+
* listener lived on the host's own emitter and was removed by
|
|
89
|
+
* `this.emitter.removeAllListeners()`.
|
|
90
|
+
*/
|
|
91
|
+
private readonly ownedWorkerLogBridgeDetachers;
|
|
69
92
|
private _taskManager?;
|
|
70
93
|
private _taskManagerConfig?;
|
|
71
94
|
private toolRegistry;
|
|
@@ -767,6 +790,11 @@ export declare class NeuroLink {
|
|
|
767
790
|
* @since 1.0.0
|
|
768
791
|
*/
|
|
769
792
|
generate(optionsOrPrompt: GenerateOptions | DynamicOptions | string): Promise<GenerateResult>;
|
|
793
|
+
/**
|
|
794
|
+
* Body of {@link generate}, always entered through the instance log scope
|
|
795
|
+
* so every log call beneath it is attributable to this instance.
|
|
796
|
+
*/
|
|
797
|
+
private generateInInstanceScope;
|
|
770
798
|
private fireConsumerOnErrorIfNotFired;
|
|
771
799
|
/**
|
|
772
800
|
* Curator P2-3: wraps a generate/stream call with the fallback
|
|
@@ -887,6 +915,10 @@ export declare class NeuroLink {
|
|
|
887
915
|
* Internally calls generate() and converts result format
|
|
888
916
|
*/
|
|
889
917
|
generateText(options: TextGenerationOptions): Promise<TextGenerationResult>;
|
|
918
|
+
/**
|
|
919
|
+
* Body of {@link generateText}, always entered through the instance log scope.
|
|
920
|
+
*/
|
|
921
|
+
private generateTextInInstanceScope;
|
|
890
922
|
/**
|
|
891
923
|
* REDESIGNED INTERNAL GENERATION - NO CIRCULAR DEPENDENCIES
|
|
892
924
|
*
|
|
@@ -1032,6 +1064,16 @@ export declare class NeuroLink {
|
|
|
1032
1064
|
* @throws {Error} When conversation memory operations fail (if enabled)
|
|
1033
1065
|
*/
|
|
1034
1066
|
stream(options: StreamOptions | DynamicOptions): Promise<StreamResult>;
|
|
1067
|
+
/**
|
|
1068
|
+
* Body of {@link stream}, always entered through the instance log scope.
|
|
1069
|
+
*
|
|
1070
|
+
* Note the scope covers setting the stream up, not draining it: logs
|
|
1071
|
+
* emitted while the consumer iterates the returned stream run in the
|
|
1072
|
+
* consumer's async context, so they are attributed to whatever scope the
|
|
1073
|
+
* consumer is in. Provider loops that run to completion inside this call
|
|
1074
|
+
* are covered.
|
|
1075
|
+
*/
|
|
1076
|
+
private streamInInstanceScope;
|
|
1035
1077
|
/**
|
|
1036
1078
|
* Curator P2-3 / Reviewer Finding #2: stream-fallback that also covers
|
|
1037
1079
|
* errors thrown during async iteration (e.g. LiteLLM throwing inside
|