@juspay/neurolink 12.0.1 → 12.0.3
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/browser/neurolink.min.js +343 -338
- package/dist/cli/commands/models.js +42 -0
- package/dist/core/baseProvider.js +13 -3
- package/dist/core/modules/GenerationHandler.js +5 -1
- package/dist/neurolink.d.ts +18 -0
- package/dist/neurolink.js +79 -4
- package/dist/providers/litellm/client.js +10 -2
- package/dist/utils/messageBuilder.js +130 -60
- package/dist/utils/providerErrorClassification.js +5 -1
- package/dist/utils/providerHealth.js +30 -6
- package/dist/utils/transformationUtils.d.ts +5 -0
- package/dist/utils/transformationUtils.js +25 -0
- package/package.json +1 -1
|
@@ -320,6 +320,48 @@ export class ModelsCommandFactory {
|
|
|
320
320
|
if (!argv.deprecated) {
|
|
321
321
|
models = models.filter((model) => !model.deprecated);
|
|
322
322
|
}
|
|
323
|
+
// Live-discovery fallback: providers that resolve their catalog at
|
|
324
|
+
// runtime (litellm, ollama, openai-compatible gateways) have no static
|
|
325
|
+
// registry rows, so a scoped `models list --provider litellm` printed
|
|
326
|
+
// "Found 0 models" while generate/stream against those models worked.
|
|
327
|
+
// When the static registry is empty for a single requested provider,
|
|
328
|
+
// ask the provider itself.
|
|
329
|
+
if (models.length === 0 &&
|
|
330
|
+
argv.provider &&
|
|
331
|
+
(Array.isArray(argv.provider) ? argv.provider : [argv.provider])
|
|
332
|
+
.length === 1) {
|
|
333
|
+
const providerName = String(Array.isArray(argv.provider) ? argv.provider[0] : argv.provider);
|
|
334
|
+
try {
|
|
335
|
+
const { AIProviderFactory } = await import("../../core/factory.js");
|
|
336
|
+
const provider = (await AIProviderFactory.createProvider(providerName));
|
|
337
|
+
const liveIds = typeof provider.getAvailableModels === "function"
|
|
338
|
+
? await provider.getAvailableModels()
|
|
339
|
+
: [];
|
|
340
|
+
if (liveIds.length > 0) {
|
|
341
|
+
if (spinner) {
|
|
342
|
+
spinner.succeed(`Found ${liveIds.length} models (live from ${providerName})`);
|
|
343
|
+
}
|
|
344
|
+
if (argv.format === "json") {
|
|
345
|
+
logger.always(JSON.stringify(liveIds.map((id) => ({
|
|
346
|
+
id,
|
|
347
|
+
provider: providerName,
|
|
348
|
+
source: "live",
|
|
349
|
+
})), null, 2));
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
logger.always(chalk.bold(`\n📋 Models exposed by ${providerName} (live discovery):\n`));
|
|
353
|
+
for (const id of liveIds) {
|
|
354
|
+
logger.always(` ${id}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// Live discovery is best-effort — fall through to the static
|
|
362
|
+
// (empty) listing rather than turning a listing into an error.
|
|
363
|
+
}
|
|
364
|
+
}
|
|
323
365
|
if (spinner) {
|
|
324
366
|
spinner.succeed(`Found ${models.length} models`);
|
|
325
367
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { context, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
2
|
import { directAgentTools } from "../agent/directTools.js";
|
|
3
3
|
import { defaultProviderFor } from "../factories/mediaHandlerCatalog.js";
|
|
4
|
+
import { PROVIDER_DESCRIPTORS_BY_NAME } from "../factories/providerDescriptors.js";
|
|
4
5
|
import { MiddlewareFactory } from "../middleware/factory.js";
|
|
5
6
|
import { modelSupports } from "../models/modelRegistry.js";
|
|
6
7
|
import { resolveRequestKind } from "./resolveRequestKind.js";
|
|
@@ -1243,14 +1244,23 @@ export class BaseProvider {
|
|
|
1243
1244
|
}, options, startTime);
|
|
1244
1245
|
}
|
|
1245
1246
|
async executeStandardGenerateFlow(options, startTime, model, messages, tools) {
|
|
1246
|
-
// Apply a defensive default timeout
|
|
1247
|
-
//
|
|
1247
|
+
// Apply a defensive default timeout when the caller didn't pass one.
|
|
1248
|
+
// Without this guard, AI SDK's generateText() will wait forever on
|
|
1248
1249
|
// an upstream that accepts the connection but never produces a response
|
|
1249
1250
|
// (observed against the litellm gateway when a request triggers the
|
|
1250
1251
|
// team-access denial path — connection stays open, no response is sent,
|
|
1251
1252
|
// and the matrix test hangs the entire suite). Callers can still pass
|
|
1252
1253
|
// a larger value (e.g. video generation passes 10 min).
|
|
1253
|
-
|
|
1254
|
+
//
|
|
1255
|
+
// A provider descriptor may declare a LARGER generate budget than the
|
|
1256
|
+
// 3-min floor (litellm: 300s — slow proxied models routinely need more
|
|
1257
|
+
// than 180s end-to-end even while streaming). The declared value only
|
|
1258
|
+
// ever raises the default, never lowers it: several descriptors carry
|
|
1259
|
+
// aspirational sub-180s numbers (openai 30s, bedrock 45s) that were
|
|
1260
|
+
// never enforced on this path, and enforcing them now would break
|
|
1261
|
+
// long-running generations that have always been allowed.
|
|
1262
|
+
const descriptorGenerateMs = PROVIDER_DESCRIPTORS_BY_NAME.get(this.providerName)?.timeouts?.generateMs;
|
|
1263
|
+
const effectiveTimeout = options.timeout ?? Math.max(descriptorGenerateMs ?? 0, 180_000);
|
|
1254
1264
|
const timeoutController = createTimeoutController(effectiveTimeout, this.providerName, "generate");
|
|
1255
1265
|
const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
|
|
1256
1266
|
const composedOptions = composedSignal
|
|
@@ -731,7 +731,11 @@ export class GenerationHandler {
|
|
|
731
731
|
toolName;
|
|
732
732
|
toolsUsed.push(toolName);
|
|
733
733
|
let callArgs = {};
|
|
734
|
-
if (tcRecord.
|
|
734
|
+
if (tcRecord.input) {
|
|
735
|
+
// AI SDK v6 carries tool-call arguments as `input`.
|
|
736
|
+
callArgs = tcRecord.input;
|
|
737
|
+
}
|
|
738
|
+
else if (tcRecord.args) {
|
|
735
739
|
callArgs = tcRecord.args;
|
|
736
740
|
}
|
|
737
741
|
else if (tcRecord.arguments) {
|
package/dist/neurolink.d.ts
CHANGED
|
@@ -85,6 +85,23 @@ export declare class NeuroLink {
|
|
|
85
85
|
/** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
|
|
86
86
|
private mcpArtifactStore?;
|
|
87
87
|
private _disableToolCacheForCurrentRequest;
|
|
88
|
+
/**
|
|
89
|
+
* (toolName + args) keys already served during the CURRENT request.
|
|
90
|
+
* A repeat occurrence within one request bypasses the tool-result cache:
|
|
91
|
+
* when the model deliberately re-calls a tool with identical args in the
|
|
92
|
+
* same turn (a counter, a poll, "check status again"), it wants fresh
|
|
93
|
+
* state — serving the memoized first result silently freezes stateful
|
|
94
|
+
* tools (observed live: a 5-round counter loop executed once). Cross-
|
|
95
|
+
* request dedup — BZ-664's actual goal — is untouched: the first
|
|
96
|
+
* occurrence in a request may still be served from cache. Request-scoped
|
|
97
|
+
* like _disableToolCacheForCurrentRequest above (assigned a fresh Set at
|
|
98
|
+
* request start so the router's save/restore-by-reference pattern works).
|
|
99
|
+
*/
|
|
100
|
+
private _toolCacheKeysServedThisRequest;
|
|
101
|
+
/** True only while a generate()/stream() turn is executing — the
|
|
102
|
+
* repeat-call cache bypass applies inside a turn; direct executeTool
|
|
103
|
+
* calls keep full BZ-664 cache semantics. */
|
|
104
|
+
private _generationTurnActive;
|
|
88
105
|
private mcpEnhancementsConfig?;
|
|
89
106
|
private toolCircuitBreakers;
|
|
90
107
|
private toolExecutionMetrics;
|
|
@@ -1524,6 +1541,7 @@ export declare class NeuroLink {
|
|
|
1524
1541
|
* - Annotations: skip cache for destructive tools, retry safe tools on failure
|
|
1525
1542
|
* - Middleware: apply global middleware chain before execution
|
|
1526
1543
|
*/
|
|
1544
|
+
private toolCacheRepeatKey;
|
|
1527
1545
|
private executeToolInternal;
|
|
1528
1546
|
/**
|
|
1529
1547
|
* Get tool annotations for execution decisions (cache, retry).
|
package/dist/neurolink.js
CHANGED
|
@@ -102,7 +102,7 @@ import { getWorkflow } from "./workflow/core/workflowRegistry.js";
|
|
|
102
102
|
import { runWorkflow } from "./workflow/core/workflowRunner.js";
|
|
103
103
|
import { ModelPool, classifyProviderError } from "./routing/index.js";
|
|
104
104
|
import { ClassifierRouter } from "./routing/classifierRouter.js";
|
|
105
|
-
import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, isNonRetryableProviderError as sharedIsNonRetryableProviderError, isNonRetryableForPool as sharedIsNonRetryableForPool, } from "./utils/providerErrorClassification.js";
|
|
105
|
+
import { looksLikeModelAccessDenied as sharedLooksLikeModelAccessDenied, looksLikeModelNotFound as sharedLooksLikeModelNotFound, isNonRetryableProviderError as sharedIsNonRetryableProviderError, isNonRetryableForPool as sharedIsNonRetryableForPool, } from "./utils/providerErrorClassification.js";
|
|
106
106
|
import { getErrorStatusCode } from "./utils/providerRetry.js";
|
|
107
107
|
import { detectAndRedactPII } from "./utils/piiDetector.js";
|
|
108
108
|
import { validateResponse } from "./utils/responseValidator.js";
|
|
@@ -377,6 +377,23 @@ export class NeuroLink {
|
|
|
377
377
|
/** Artifact store for externalized MCP tool outputs (set when strategy=externalize). */
|
|
378
378
|
mcpArtifactStore;
|
|
379
379
|
_disableToolCacheForCurrentRequest = false;
|
|
380
|
+
/**
|
|
381
|
+
* (toolName + args) keys already served during the CURRENT request.
|
|
382
|
+
* A repeat occurrence within one request bypasses the tool-result cache:
|
|
383
|
+
* when the model deliberately re-calls a tool with identical args in the
|
|
384
|
+
* same turn (a counter, a poll, "check status again"), it wants fresh
|
|
385
|
+
* state — serving the memoized first result silently freezes stateful
|
|
386
|
+
* tools (observed live: a 5-round counter loop executed once). Cross-
|
|
387
|
+
* request dedup — BZ-664's actual goal — is untouched: the first
|
|
388
|
+
* occurrence in a request may still be served from cache. Request-scoped
|
|
389
|
+
* like _disableToolCacheForCurrentRequest above (assigned a fresh Set at
|
|
390
|
+
* request start so the router's save/restore-by-reference pattern works).
|
|
391
|
+
*/
|
|
392
|
+
_toolCacheKeysServedThisRequest = new Set();
|
|
393
|
+
/** True only while a generate()/stream() turn is executing — the
|
|
394
|
+
* repeat-call cache bypass applies inside a turn; direct executeTool
|
|
395
|
+
* calls keep full BZ-664 cache semantics. */
|
|
396
|
+
_generationTurnActive = false;
|
|
380
397
|
mcpEnhancementsConfig;
|
|
381
398
|
// Enhanced error handling support
|
|
382
399
|
toolCircuitBreakers = new Map();
|
|
@@ -3211,6 +3228,16 @@ Current user's request: ${currentInput}`;
|
|
|
3211
3228
|
// String prompts are immutable, so they pass through.
|
|
3212
3229
|
if (typeof optionsOrPrompt !== "string") {
|
|
3213
3230
|
optionsOrPrompt = cloneOptionsForCallIsolation(optionsOrPrompt);
|
|
3231
|
+
// The deprecated `conversationHistory` field is not wired into message
|
|
3232
|
+
// building — messages passed there never reach the model, which reads
|
|
3233
|
+
// as "the SDK forgot my context" rather than a caller bug. Warn loudly
|
|
3234
|
+
// instead of failing silently; `conversationMessages` is the wired path.
|
|
3235
|
+
const legacyOpts = optionsOrPrompt;
|
|
3236
|
+
if (Array.isArray(legacyOpts.conversationHistory) &&
|
|
3237
|
+
legacyOpts.conversationHistory.length > 0 &&
|
|
3238
|
+
!legacyOpts.conversationMessages) {
|
|
3239
|
+
logger.warn("[NeuroLink.generate] `conversationHistory` is deprecated and NOT passed to the model — use `conversationMessages` (ChatMessage[]) instead.");
|
|
3240
|
+
}
|
|
3214
3241
|
}
|
|
3215
3242
|
// Retrieve once at the public call boundary so fallback attempts reuse the
|
|
3216
3243
|
// same grounding block and internal preparation cannot inject it twice.
|
|
@@ -3347,9 +3374,14 @@ Current user's request: ${currentInput}`;
|
|
|
3347
3374
|
// mid-fallback; cloneOptionsForCallIsolation keeps abortSignal
|
|
3348
3375
|
// by-reference, so the retried options observe the same signal.
|
|
3349
3376
|
const callerAborted = () => callOpts.abortSignal?.aborted === true;
|
|
3377
|
+
// modelChain-only orchestration advances on two error shapes the next
|
|
3378
|
+
// member can actually fix: access-denied (the original gate) and
|
|
3379
|
+
// model-not-found ("Invalid model name" from a gateway that doesn't
|
|
3380
|
+
// serve that member — a chain written for one LiteLLM deployment must
|
|
3381
|
+
// not hard-fail on another just because a member is absent there).
|
|
3350
3382
|
const shouldOrchestrateFallback = (err) => effectiveCallback
|
|
3351
3383
|
? !(isAbortError(err) && callerAborted())
|
|
3352
|
-
: looksLikeModelAccessDenied(err);
|
|
3384
|
+
: looksLikeModelAccessDenied(err) || sharedLooksLikeModelNotFound(err);
|
|
3353
3385
|
if (!shouldOrchestrateFallback(lastError)) {
|
|
3354
3386
|
throw lastError;
|
|
3355
3387
|
}
|
|
@@ -3494,6 +3526,8 @@ Current user's request: ${currentInput}`;
|
|
|
3494
3526
|
}
|
|
3495
3527
|
finally {
|
|
3496
3528
|
this._disableToolCacheForCurrentRequest = false;
|
|
3529
|
+
this._toolCacheKeysServedThisRequest = new Set();
|
|
3530
|
+
this._generationTurnActive = false;
|
|
3497
3531
|
generateSpan.end();
|
|
3498
3532
|
}
|
|
3499
3533
|
}
|
|
@@ -3512,6 +3546,8 @@ Current user's request: ${currentInput}`;
|
|
|
3512
3546
|
await this.resolveDynamicOptions(options);
|
|
3513
3547
|
options.model = resolveModel(options.model, this.modelAliasConfig);
|
|
3514
3548
|
this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
|
|
3549
|
+
this._toolCacheKeysServedThisRequest = new Set();
|
|
3550
|
+
this._generationTurnActive = true;
|
|
3515
3551
|
generateSpan.setAttribute("neurolink.provider", options.provider || "default");
|
|
3516
3552
|
generateSpan.setAttribute("neurolink.model", options.model || "default");
|
|
3517
3553
|
generateSpan.setAttribute("neurolink.input_length", typeof optionsOrPrompt === "string"
|
|
@@ -6657,6 +6693,8 @@ Current user's request: ${currentInput}`;
|
|
|
6657
6693
|
const streamIsRoot = !trace.getSpan(context.active());
|
|
6658
6694
|
const spanStartTime = Date.now();
|
|
6659
6695
|
this._disableToolCacheForCurrentRequest = !!options.disableToolCache;
|
|
6696
|
+
this._toolCacheKeysServedThisRequest = new Set();
|
|
6697
|
+
this._generationTurnActive = true;
|
|
6660
6698
|
try {
|
|
6661
6699
|
options.model = resolveModel(options.model, this.modelAliasConfig);
|
|
6662
6700
|
const startTime = Date.now();
|
|
@@ -7289,6 +7327,8 @@ Current user's request: ${currentInput}`;
|
|
|
7289
7327
|
}
|
|
7290
7328
|
finally {
|
|
7291
7329
|
self._disableToolCacheForCurrentRequest = false;
|
|
7330
|
+
self._toolCacheKeysServedThisRequest = new Set();
|
|
7331
|
+
self._generationTurnActive = false;
|
|
7292
7332
|
params.streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - params.spanStartTime);
|
|
7293
7333
|
params.streamSpan.end();
|
|
7294
7334
|
}
|
|
@@ -7588,6 +7628,8 @@ Current user's request: ${currentInput}`;
|
|
|
7588
7628
|
}
|
|
7589
7629
|
}
|
|
7590
7630
|
self._disableToolCacheForCurrentRequest = false;
|
|
7631
|
+
self._toolCacheKeysServedThisRequest = new Set();
|
|
7632
|
+
self._generationTurnActive = false;
|
|
7591
7633
|
cleanupListeners();
|
|
7592
7634
|
streamSpan.setAttribute("neurolink.response_time_ms", Date.now() - spanStartTime);
|
|
7593
7635
|
streamSpan.setAttribute(ATTR.NL_OUTPUT_LENGTH, accumulatedContent.length);
|
|
@@ -10032,6 +10074,16 @@ Current user's request: ${currentInput}`;
|
|
|
10032
10074
|
* - Annotations: skip cache for destructive tools, retry safe tools on failure
|
|
10033
10075
|
* - Middleware: apply global middleware chain before execution
|
|
10034
10076
|
*/
|
|
10077
|
+
toolCacheRepeatKey(toolName, params) {
|
|
10078
|
+
try {
|
|
10079
|
+
return `${toolName}:${JSON.stringify(params) ?? ""}`;
|
|
10080
|
+
}
|
|
10081
|
+
catch {
|
|
10082
|
+
// Unserializable args (circular refs) — no repeat tracking; the
|
|
10083
|
+
// ToolResultCache's own keying handles (or rejects) them as before.
|
|
10084
|
+
return undefined;
|
|
10085
|
+
}
|
|
10086
|
+
}
|
|
10035
10087
|
async executeToolInternal(toolName, params, options, HITLState) {
|
|
10036
10088
|
const functionTag = "NeuroLink.executeToolInternal";
|
|
10037
10089
|
// === MCP ENHANCEMENT: Infer annotations for cache/retry decisions ===
|
|
@@ -10049,13 +10101,24 @@ Current user's request: ${currentInput}`;
|
|
|
10049
10101
|
__ctx: options.authContext ?? this.toolExecutionContext,
|
|
10050
10102
|
}
|
|
10051
10103
|
: params;
|
|
10052
|
-
|
|
10104
|
+
const repeatKey = this._generationTurnActive
|
|
10105
|
+
? this.toolCacheRepeatKey(toolName, cacheParams)
|
|
10106
|
+
: undefined;
|
|
10107
|
+
const isRepeatCallThisRequest = repeatKey !== undefined &&
|
|
10108
|
+
this._toolCacheKeysServedThisRequest.has(repeatKey);
|
|
10109
|
+
if (repeatKey !== undefined) {
|
|
10110
|
+
this._toolCacheKeysServedThisRequest.add(repeatKey);
|
|
10111
|
+
}
|
|
10112
|
+
if (isCacheEnabled && toolResultCache && !isRepeatCallThisRequest) {
|
|
10053
10113
|
const cached = toolResultCache.getCachedResult(toolName, cacheParams);
|
|
10054
10114
|
if (cached !== undefined) {
|
|
10055
10115
|
logger.debug(`[${functionTag}] Cache HIT for tool: ${toolName}`);
|
|
10056
10116
|
return cached;
|
|
10057
10117
|
}
|
|
10058
10118
|
}
|
|
10119
|
+
else if (isRepeatCallThisRequest) {
|
|
10120
|
+
logger.debug(`[${functionTag}] Repeat call within this request — bypassing tool cache for: ${toolName}`);
|
|
10121
|
+
}
|
|
10059
10122
|
// === MCP ENHANCEMENT: Middleware chain wrapper ===
|
|
10060
10123
|
const executeWithMiddleware = async (executeFn) => {
|
|
10061
10124
|
if (this.mcpToolMiddlewares.length === 0) {
|
|
@@ -11500,7 +11563,19 @@ Current user's request: ${currentInput}`;
|
|
|
11500
11563
|
? { __ctx: this.toolExecutionContext }
|
|
11501
11564
|
: {}),
|
|
11502
11565
|
};
|
|
11503
|
-
|
|
11566
|
+
// Same repeat-within-request bypass as executeToolInternal: a model
|
|
11567
|
+
// re-calling the identical tool+args in one turn wants fresh state.
|
|
11568
|
+
const externalRepeatKey = this._generationTurnActive
|
|
11569
|
+
? this.toolCacheRepeatKey(toolName, cacheKeyArgs)
|
|
11570
|
+
: undefined;
|
|
11571
|
+
const isExternalRepeatThisRequest = externalRepeatKey !== undefined &&
|
|
11572
|
+
this._toolCacheKeysServedThisRequest.has(externalRepeatKey);
|
|
11573
|
+
if (externalRepeatKey !== undefined) {
|
|
11574
|
+
this._toolCacheKeysServedThisRequest.add(externalRepeatKey);
|
|
11575
|
+
}
|
|
11576
|
+
if (cacheEnabled &&
|
|
11577
|
+
this.mcpToolResultCache &&
|
|
11578
|
+
!isExternalRepeatThisRequest) {
|
|
11504
11579
|
const cached = this.mcpToolResultCache.getCachedResult(toolName, cacheKeyArgs);
|
|
11505
11580
|
if (cached !== undefined) {
|
|
11506
11581
|
mcpLogger.debug(`[NeuroLink] Tool result cache HIT: ${toolName} on ${serverId}`);
|
|
@@ -367,8 +367,11 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
367
367
|
{
|
|
368
368
|
match: (ctx) => /ECONNREFUSED|Failed to fetch/.test(ctx.message),
|
|
369
369
|
errorClass: NetworkError,
|
|
370
|
+
// Name the base URL this instance actually dialed — per-request
|
|
371
|
+
// credentials can override LITELLM_BASE_URL, and an error pointing
|
|
372
|
+
// at the env value sends the caller debugging the wrong host.
|
|
370
373
|
message: () => "LiteLLM proxy server not available. Please start the LiteLLM proxy server at " +
|
|
371
|
-
|
|
374
|
+
redactUrlCredentials(this.config.baseURL),
|
|
372
375
|
},
|
|
373
376
|
{
|
|
374
377
|
match: (ctx) => /API_KEY_INVALID|Invalid API key/.test(ctx.message),
|
|
@@ -420,7 +423,12 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
420
423
|
return this.getFallbackModels();
|
|
421
424
|
}
|
|
422
425
|
async fetchModelsFromAPI() {
|
|
423
|
-
|
|
426
|
+
// Tolerate a `/v1`-suffixed base URL. Chat appends /chat/completions, so
|
|
427
|
+
// deployments are commonly configured with base = https://host/v1 — but
|
|
428
|
+
// appending /v1/models to that yields /v1/v1/models, which LiteLLM 404s,
|
|
429
|
+
// and discovery silently degrades to the hardcoded fallback list.
|
|
430
|
+
const root = stripTrailingSlash(this.config.baseURL).replace(/\/v1$/, "");
|
|
431
|
+
const modelsUrl = `${root}/v1/models`;
|
|
424
432
|
const proxyFetch = createProxyFetch();
|
|
425
433
|
const controller = new AbortController();
|
|
426
434
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
@@ -2184,76 +2184,146 @@ pdfFiles, provider, model, audioFiles = []) {
|
|
|
2184
2184
|
}));
|
|
2185
2185
|
}
|
|
2186
2186
|
else {
|
|
2187
|
-
//
|
|
2188
|
-
//
|
|
2189
|
-
|
|
2187
|
+
// No native PDF support: inline the PDF's text layer, clearly labeled per
|
|
2188
|
+
// file. Image-only conversion sent content a text-only backend can never
|
|
2189
|
+
// read — observed live as the model flatly claiming no file was attached
|
|
2190
|
+
// — and for proxy providers (litellm/openrouter) supportsVision() is a
|
|
2191
|
+
// pass-through, so vision cannot be trusted to carry the content either.
|
|
2192
|
+
// Text is therefore always included (primary for text-only backends,
|
|
2193
|
+
// extra grounding otherwise); page images are appended below only when
|
|
2194
|
+
// the provider may actually see them. A PDF with no text layer (pure
|
|
2195
|
+
// scan) gets an explicit note rather than silence, so the model
|
|
2196
|
+
// acknowledges an unreadable attachment instead of denying it exists.
|
|
2197
|
+
const providerCanSeeImages = ProviderImageAdapter.supportsVision(provider, model);
|
|
2190
2198
|
for (const pdf of pdfFiles) {
|
|
2199
|
+
const name = safeBasename(pdf.filename);
|
|
2191
2200
|
try {
|
|
2192
|
-
const
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2195
|
-
// and it used to hardcode scale 2.0 — silently overriding the
|
|
2196
|
-
// lowered PDF_LIMITS.DEFAULT_SCALE and keeping the memory cost the
|
|
2197
|
-
// issue reports (a 100-page render at 2.0 is ~776MB; 1.5 is ~44%
|
|
2198
|
-
// fewer pixels per page). Callers can raise it back per request.
|
|
2199
|
-
scale: pdf.scale ?? PDF_LIMITS.DEFAULT_SCALE,
|
|
2200
|
-
// Page ceiling guards token overflow; also now caller-adjustable
|
|
2201
|
-
// rather than a constant nothing could reach.
|
|
2202
|
-
maxPages: effectiveMaxPages,
|
|
2201
|
+
const { PDFParse } = await import("pdf-parse");
|
|
2202
|
+
const parser = new PDFParse({
|
|
2203
|
+
data: new Uint8Array(pdf.buffer),
|
|
2203
2204
|
...(pdf.password ? { password: pdf.password } : {}), // #258
|
|
2204
|
-
...(pdf.maxCanvasPixels
|
|
2205
|
-
? { maxCanvasPixels: pdf.maxCanvasPixels }
|
|
2206
|
-
: {}), // #260
|
|
2207
2205
|
});
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
`failed to render and were omitted (page ${conversionResult.errors.map((e) => e.page).join(", ")}).`);
|
|
2206
|
+
try {
|
|
2207
|
+
const extracted = await parser.getText();
|
|
2208
|
+
const pdfText = (extracted?.text ?? "").trim();
|
|
2209
|
+
if (pdfText.length > 0) {
|
|
2210
|
+
content.push({
|
|
2211
|
+
type: "text",
|
|
2212
|
+
text: `\n[Attached PDF: ${name}]\n${pdfText}\n[End of PDF: ${name}]`,
|
|
2213
|
+
});
|
|
2214
|
+
logger.info(`[PDF→Text] ✅ Extracted text for non-vision provider ${provider}: ${name} (${pdfText.length} chars)`);
|
|
2215
|
+
}
|
|
2216
|
+
else {
|
|
2217
|
+
content.push({
|
|
2218
|
+
type: "text",
|
|
2219
|
+
text: `\n[Attached PDF: ${name} — no extractable text layer (likely a scanned document); its contents are unavailable to this text-only model.]`,
|
|
2220
|
+
});
|
|
2221
|
+
logger.warn(`[PDF→Text] ${name} has no text layer; provider ${provider} cannot see images — content unavailable`);
|
|
2222
|
+
}
|
|
2226
2223
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
conversionResult.images.forEach((base64Image, pageIndex) => {
|
|
2230
|
-
content.push({
|
|
2231
|
-
type: "image",
|
|
2232
|
-
image: base64Image,
|
|
2233
|
-
mimeType: "image/png",
|
|
2234
|
-
});
|
|
2235
|
-
logger.debug(`[PDF→Image] Added page ${pageIndex + 1}/${conversionResult.pageCount} of ${pdf.filename}`);
|
|
2236
|
-
});
|
|
2237
|
-
// Log any warnings from conversion
|
|
2238
|
-
if (conversionResult.warnings) {
|
|
2239
|
-
conversionResult.warnings.forEach((warning) => {
|
|
2240
|
-
logger.warn(`[PDF→Image] ${warning}`);
|
|
2241
|
-
});
|
|
2224
|
+
finally {
|
|
2225
|
+
await parser.destroy?.();
|
|
2242
2226
|
}
|
|
2243
2227
|
}
|
|
2244
2228
|
catch (error) {
|
|
2245
2229
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2246
|
-
logger.error(`[PDF→
|
|
2247
|
-
// #258: password errors are
|
|
2248
|
-
//
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2230
|
+
logger.error(`[PDF→Text] ❌ Failed to parse ${name}: ${errorMessage}`);
|
|
2231
|
+
// #258: password errors are actionable and must surface. pdf-parse
|
|
2232
|
+
// raises pdf.js's PasswordException (not our typed PDF_PASSWORD_*
|
|
2233
|
+
// codes), so detect by name/message shape. When the provider can see
|
|
2234
|
+
// images, defer the throw to the image-conversion path below — it
|
|
2235
|
+
// raises the canonical typed error with the "supply the password"
|
|
2236
|
+
// guidance callers and tests rely on.
|
|
2237
|
+
const isPasswordError = error?.name === "PasswordException" ||
|
|
2238
|
+
/password/i.test(errorMessage);
|
|
2239
|
+
if (isPasswordError) {
|
|
2240
|
+
if (!providerCanSeeImages) {
|
|
2241
|
+
throw error;
|
|
2242
|
+
}
|
|
2243
|
+
// Image branch will produce the canonical typed password error.
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
content.push({
|
|
2247
|
+
type: "text",
|
|
2248
|
+
text: `\n[Attached PDF: ${name} — could not be parsed (${errorMessage}); its contents are unavailable.]`,
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// Page images in addition to the text, for providers that may actually
|
|
2253
|
+
// see them (vision models, and proxies whose upstream might).
|
|
2254
|
+
if (providerCanSeeImages) {
|
|
2255
|
+
logger.info(`[PDF→Image] Provider ${provider} doesn't support native PDF. Converting ${pdfFiles.length} PDF(s) to images...`);
|
|
2256
|
+
for (const pdf of pdfFiles) {
|
|
2257
|
+
try {
|
|
2258
|
+
const effectiveMaxPages = pdf.maxPages ?? PDF_LIMITS.DEFAULT_MAX_PAGES;
|
|
2259
|
+
const conversionResult = await PDFImageConverter.convertToImages(pdf.buffer, {
|
|
2260
|
+
// #297: this is the only PDF→image call the product actually makes,
|
|
2261
|
+
// and it used to hardcode scale 2.0 — silently overriding the
|
|
2262
|
+
// lowered PDF_LIMITS.DEFAULT_SCALE and keeping the memory cost the
|
|
2263
|
+
// issue reports (a 100-page render at 2.0 is ~776MB; 1.5 is ~44%
|
|
2264
|
+
// fewer pixels per page). Callers can raise it back per request.
|
|
2265
|
+
scale: pdf.scale ?? PDF_LIMITS.DEFAULT_SCALE,
|
|
2266
|
+
// Page ceiling guards token overflow; also now caller-adjustable
|
|
2267
|
+
// rather than a constant nothing could reach.
|
|
2268
|
+
maxPages: effectiveMaxPages,
|
|
2269
|
+
...(pdf.password ? { password: pdf.password } : {}), // #258
|
|
2270
|
+
...(pdf.maxCanvasPixels
|
|
2271
|
+
? { maxCanvasPixels: pdf.maxCanvasPixels }
|
|
2272
|
+
: {}), // #260
|
|
2273
|
+
});
|
|
2274
|
+
// The renderer stops at maxPages, so a longer document is silently
|
|
2275
|
+
// truncated — say so rather than letting the model answer from a
|
|
2276
|
+
// partial document as though it had the whole thing.
|
|
2277
|
+
//
|
|
2278
|
+
// Keyed on the cap being reached, not on pdf.pageCount: that field is
|
|
2279
|
+
// null whenever `input.content` omits `metadata.pages`, which is the
|
|
2280
|
+
// common case, so a page-count comparison would simply never fire
|
|
2281
|
+
// there. Reaching the cap is also unambiguous — a short count caused by
|
|
2282
|
+
// per-page render failures (#294 isolates those into `errors`) would
|
|
2283
|
+
// otherwise be misreported as a maxPages truncation.
|
|
2284
|
+
if (conversionResult.pageCount >= effectiveMaxPages) {
|
|
2285
|
+
logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)} hit the ${effectiveMaxPages}-page ` +
|
|
2286
|
+
`conversion limit. Any pages beyond that were not sent — the model may be ` +
|
|
2287
|
+
`answering from a partial document. Raise pdfOptions.maxPages or split the file.`);
|
|
2288
|
+
}
|
|
2289
|
+
if (conversionResult.errors && conversionResult.errors.length > 0) {
|
|
2290
|
+
logger.warn(`[PDF→Image] ${safeBasename(pdf.filename)}: ${conversionResult.errors.length} page(s) ` +
|
|
2291
|
+
`failed to render and were omitted (page ${conversionResult.errors.map((e) => e.page).join(", ")}).`);
|
|
2292
|
+
}
|
|
2293
|
+
logger.info(`[PDF→Image] ✅ Converted ${pdf.filename}: ${conversionResult.pageCount} page(s) → images`);
|
|
2294
|
+
// Add each page as an ImagePart (raw base64, not data: URI — see SSRF note above)
|
|
2295
|
+
conversionResult.images.forEach((base64Image, pageIndex) => {
|
|
2296
|
+
content.push({
|
|
2297
|
+
type: "image",
|
|
2298
|
+
image: base64Image,
|
|
2299
|
+
mimeType: "image/png",
|
|
2300
|
+
});
|
|
2301
|
+
logger.debug(`[PDF→Image] Added page ${pageIndex + 1}/${conversionResult.pageCount} of ${pdf.filename}`);
|
|
2302
|
+
});
|
|
2303
|
+
// Log any warnings from conversion
|
|
2304
|
+
if (conversionResult.warnings) {
|
|
2305
|
+
conversionResult.warnings.forEach((warning) => {
|
|
2306
|
+
logger.warn(`[PDF→Image] ${warning}`);
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
catch (error) {
|
|
2311
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2312
|
+
logger.error(`[PDF→Image] ❌ Failed to convert ${pdf.filename}: ${errorMessage}`);
|
|
2313
|
+
// #258: password errors are already actionable typed errors — re-throw
|
|
2314
|
+
// them unwrapped so the "supply a password" guidance isn't buried.
|
|
2315
|
+
const code = error?.code;
|
|
2316
|
+
if (code === "PDF_PASSWORD_REQUIRED" ||
|
|
2317
|
+
code === "PDF_INCORRECT_PASSWORD") {
|
|
2318
|
+
throw error;
|
|
2319
|
+
}
|
|
2320
|
+
// Re-throw so the user knows PDF processing failed. The inlined
|
|
2321
|
+
// text layer above does NOT soften this: conversion failures
|
|
2322
|
+
// include caller mistakes (e.g. an invalid maxCanvasPixels, #260)
|
|
2323
|
+
// that must surface rather than be silently downgraded.
|
|
2324
|
+
throw new Error(`PDF to image conversion failed for ${pdf.filename}: ${errorMessage}. ` +
|
|
2325
|
+
`Provider ${provider} doesn't support native PDFs and image conversion failed.`, { cause: error });
|
|
2253
2326
|
}
|
|
2254
|
-
// Re-throw so the user knows PDF processing failed
|
|
2255
|
-
throw new Error(`PDF to image conversion failed for ${pdf.filename}: ${errorMessage}. ` +
|
|
2256
|
-
`Provider ${provider} doesn't support native PDFs and image conversion failed.`, { cause: error });
|
|
2257
2327
|
}
|
|
2258
2328
|
}
|
|
2259
2329
|
}
|
|
@@ -67,7 +67,11 @@ export function looksLikeModelNotFound(error) {
|
|
|
67
67
|
lower.includes("not_found_error") ||
|
|
68
68
|
msg.includes("NOT_FOUND") ||
|
|
69
69
|
lower.includes("does not exist") ||
|
|
70
|
-
lower.includes("unknown model")
|
|
70
|
+
lower.includes("unknown model") ||
|
|
71
|
+
// LiteLLM's phrasing for a model id its router doesn't serve — a 400,
|
|
72
|
+
// not a 404: "Invalid model name passed in model=... Call `/v1/models`
|
|
73
|
+
// to view available models for your key."
|
|
74
|
+
lower.includes("invalid model name")) &&
|
|
71
75
|
namesAModel);
|
|
72
76
|
}
|
|
73
77
|
/**
|
|
@@ -808,13 +808,37 @@ export class ProviderHealthChecker {
|
|
|
808
808
|
healthStatus.recommendations.push("Set LITELLM_BASE_URL to a valid URL (e.g., http://localhost:4000)");
|
|
809
809
|
return;
|
|
810
810
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
811
|
+
// Only pin the availability check to a specific model when the user
|
|
812
|
+
// explicitly configured one. The fallback default ("openai/gpt-4o-mini")
|
|
813
|
+
// is a guess, not configuration — proxies that serve a different model
|
|
814
|
+
// set (every self-hosted gateway) were reported "Not configured" here
|
|
815
|
+
// while generate/stream against them worked fine with explicit models.
|
|
816
|
+
const configuredModel = process.env.LITELLM_MODEL;
|
|
817
|
+
let failureReason;
|
|
818
|
+
if (configuredModel) {
|
|
819
|
+
const availability = await this.checkLiteLLMAvailability({
|
|
820
|
+
model: configuredModel,
|
|
821
|
+
timeout,
|
|
822
|
+
});
|
|
823
|
+
if (!availability.available) {
|
|
824
|
+
failureReason = availability.reason ?? "unknown error";
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
// No configured model: a reachable proxy with any models counts.
|
|
829
|
+
try {
|
|
830
|
+
const models = await this.getLiteLLMAvailableModels(timeout);
|
|
831
|
+
if (models.length === 0) {
|
|
832
|
+
failureReason = "LiteLLM returned an empty model list";
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
failureReason = error instanceof Error ? error.message : String(error);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (failureReason !== undefined) {
|
|
816
840
|
healthStatus.isConfigured = false;
|
|
817
|
-
healthStatus.configurationIssues.push(`LiteLLM runtime check failed: ${
|
|
841
|
+
healthStatus.configurationIssues.push(`LiteLLM runtime check failed: ${failureReason}`);
|
|
818
842
|
healthStatus.recommendations.push("Start the LiteLLM proxy and ensure the configured model is available from /v1/models");
|
|
819
843
|
return;
|
|
820
844
|
}
|
|
@@ -33,6 +33,11 @@ export declare function transformToolExecutionsForMCP(toolExecutions?: unknown[]
|
|
|
33
33
|
executionTime: number;
|
|
34
34
|
success: boolean;
|
|
35
35
|
serverId?: string;
|
|
36
|
+
params?: unknown;
|
|
37
|
+
output?: unknown;
|
|
38
|
+
startedAt?: number;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
error?: string;
|
|
36
41
|
}>;
|
|
37
42
|
/**
|
|
38
43
|
* Convert ToolExecutionRecord entries (or legacy-shaped entries) to the
|