@juspay/neurolink 9.42.0 → 9.42.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/dist/auth/anthropicOAuth.js +12 -0
  3. package/dist/browser/neurolink.min.js +337 -336
  4. package/dist/cli/commands/mcp.d.ts +6 -0
  5. package/dist/cli/commands/mcp.js +188 -184
  6. package/dist/cli/commands/proxy.js +537 -518
  7. package/dist/core/baseProvider.d.ts +6 -1
  8. package/dist/core/baseProvider.js +208 -230
  9. package/dist/core/factory.d.ts +3 -0
  10. package/dist/core/factory.js +138 -188
  11. package/dist/evaluation/pipeline/evaluationPipeline.js +5 -2
  12. package/dist/evaluation/scorers/scorerRegistry.d.ts +3 -0
  13. package/dist/evaluation/scorers/scorerRegistry.js +353 -282
  14. package/dist/lib/auth/anthropicOAuth.js +12 -0
  15. package/dist/lib/core/baseProvider.d.ts +6 -1
  16. package/dist/lib/core/baseProvider.js +208 -230
  17. package/dist/lib/core/factory.d.ts +3 -0
  18. package/dist/lib/core/factory.js +138 -188
  19. package/dist/lib/evaluation/pipeline/evaluationPipeline.js +5 -2
  20. package/dist/lib/evaluation/scorers/scorerRegistry.d.ts +3 -0
  21. package/dist/lib/evaluation/scorers/scorerRegistry.js +353 -282
  22. package/dist/lib/mcp/toolRegistry.d.ts +2 -0
  23. package/dist/lib/mcp/toolRegistry.js +32 -31
  24. package/dist/lib/neurolink.d.ts +38 -0
  25. package/dist/lib/neurolink.js +1858 -1689
  26. package/dist/lib/providers/googleAiStudio.js +0 -5
  27. package/dist/lib/providers/googleVertex.d.ts +10 -0
  28. package/dist/lib/providers/googleVertex.js +436 -444
  29. package/dist/lib/providers/litellm.d.ts +1 -0
  30. package/dist/lib/providers/litellm.js +73 -64
  31. package/dist/lib/providers/ollama.js +17 -4
  32. package/dist/lib/providers/openAI.d.ts +2 -0
  33. package/dist/lib/providers/openAI.js +139 -140
  34. package/dist/lib/proxy/claudeFormat.js +12 -4
  35. package/dist/lib/proxy/oauthFetch.js +298 -318
  36. package/dist/lib/proxy/proxyConfig.js +3 -1
  37. package/dist/lib/proxy/proxyFetch.js +250 -222
  38. package/dist/lib/proxy/requestLogger.js +132 -45
  39. package/dist/lib/proxy/sseInterceptor.js +36 -11
  40. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +10 -1
  41. package/dist/lib/server/routes/claudeProxyRoutes.js +2726 -2272
  42. package/dist/lib/services/server/ai/observability/instrumentation.js +194 -218
  43. package/dist/lib/tasks/backends/bullmqBackend.js +24 -18
  44. package/dist/lib/tasks/store/redisTaskStore.js +23 -16
  45. package/dist/lib/tasks/taskManager.d.ts +2 -0
  46. package/dist/lib/tasks/taskManager.js +100 -5
  47. package/dist/lib/telemetry/telemetryService.js +9 -5
  48. package/dist/lib/types/proxyTypes.d.ts +124 -1
  49. package/dist/lib/utils/providerHealth.d.ts +1 -0
  50. package/dist/lib/utils/providerHealth.js +46 -31
  51. package/dist/lib/utils/providerUtils.js +11 -22
  52. package/dist/mcp/toolRegistry.d.ts +2 -0
  53. package/dist/mcp/toolRegistry.js +32 -31
  54. package/dist/neurolink.d.ts +38 -0
  55. package/dist/neurolink.js +1858 -1689
  56. package/dist/providers/googleAiStudio.js +0 -5
  57. package/dist/providers/googleVertex.d.ts +10 -0
  58. package/dist/providers/googleVertex.js +436 -444
  59. package/dist/providers/litellm.d.ts +1 -0
  60. package/dist/providers/litellm.js +73 -64
  61. package/dist/providers/ollama.js +17 -4
  62. package/dist/providers/openAI.d.ts +2 -0
  63. package/dist/providers/openAI.js +139 -140
  64. package/dist/proxy/claudeFormat.js +12 -4
  65. package/dist/proxy/oauthFetch.js +298 -318
  66. package/dist/proxy/proxyConfig.js +3 -1
  67. package/dist/proxy/proxyFetch.js +250 -222
  68. package/dist/proxy/requestLogger.js +132 -45
  69. package/dist/proxy/sseInterceptor.js +36 -11
  70. package/dist/server/routes/claudeProxyRoutes.d.ts +10 -1
  71. package/dist/server/routes/claudeProxyRoutes.js +2726 -2272
  72. package/dist/services/server/ai/observability/instrumentation.js +194 -218
  73. package/dist/tasks/backends/bullmqBackend.js +24 -18
  74. package/dist/tasks/store/redisTaskStore.js +23 -16
  75. package/dist/tasks/taskManager.d.ts +2 -0
  76. package/dist/tasks/taskManager.js +100 -5
  77. package/dist/telemetry/telemetryService.js +9 -5
  78. package/dist/types/proxyTypes.d.ts +124 -1
  79. package/dist/utils/providerHealth.d.ts +1 -0
  80. package/dist/utils/providerHealth.js +46 -31
  81. package/dist/utils/providerUtils.js +12 -22
  82. package/package.json +3 -2
  83. package/scripts/observability/check-proxy-telemetry.mjs +1 -1
  84. package/scripts/observability/manage-local-openobserve.sh +36 -5
@@ -158,7 +158,28 @@ export class TaskManager {
158
158
  await backend.schedule(task, (t) => this.onTaskTick(t));
159
159
  }
160
160
  catch (err) {
161
- await store.delete(task.id);
161
+ this.callbacks.delete(task.id);
162
+ try {
163
+ await store.delete(task.id);
164
+ }
165
+ catch (cleanupError) {
166
+ // Deletion failed — task remains persisted as active. Attempt to mark it
167
+ // failed so it reaches a terminal state and operators can identify it.
168
+ logger.error("[TaskManager] Failed to clean up task after schedule error — task may remain persisted as active", {
169
+ taskId: task.id,
170
+ scheduleError: String(err),
171
+ cleanupError: String(cleanupError),
172
+ });
173
+ try {
174
+ await store.update(task.id, { status: "failed" });
175
+ }
176
+ catch (terminalError) {
177
+ logger.error("[TaskManager] Failed to force task to terminal state — manual cleanup required", {
178
+ taskId: task.id,
179
+ error: String(terminalError),
180
+ });
181
+ }
182
+ }
162
183
  throw err;
163
184
  }
164
185
  this.emit("task:created", task);
@@ -209,6 +230,7 @@ export class TaskManager {
209
230
  taskUpdates[field] = updates[field];
210
231
  }
211
232
  }
233
+ const shouldClearHistory = updates.mode !== undefined && updates.mode !== "continuation";
212
234
  // Special-case: mode changes require sessionId handling
213
235
  if (updates.mode !== undefined) {
214
236
  if (updates.mode === "continuation" && !existing.sessionId) {
@@ -216,14 +238,39 @@ export class TaskManager {
216
238
  }
217
239
  else if (updates.mode !== "continuation") {
218
240
  taskUpdates.sessionId = undefined;
219
- await store.clearHistory(taskId);
220
241
  }
221
242
  }
222
243
  const updated = await store.update(taskId, taskUpdates);
223
244
  // Re-schedule if schedule changed and task is active
224
245
  if (updates.schedule && updated.status === "active") {
246
+ const attemptedSchedule = updated.schedule;
225
247
  await backend.cancel(taskId);
226
- await backend.schedule(updated, (t) => this.onTaskTick(t));
248
+ try {
249
+ await backend.schedule(updated, (t) => this.onTaskTick(t));
250
+ }
251
+ catch (error) {
252
+ await this.restoreScheduledTask(existing, "update schedule rollback");
253
+ await this.rollbackTaskUpdate(taskId, existing, error);
254
+ throw TaskError.create("SCHEDULE_FAILED", `Failed to update schedule for task ${taskId}`, {
255
+ cause: error instanceof Error ? error : undefined,
256
+ details: {
257
+ taskId,
258
+ previousSchedule: existing.schedule,
259
+ attemptedSchedule,
260
+ },
261
+ });
262
+ }
263
+ }
264
+ if (shouldClearHistory) {
265
+ try {
266
+ await store.clearHistory(taskId);
267
+ }
268
+ catch (error) {
269
+ logger.warn("[TaskManager] Failed to clear task history after mode update", {
270
+ taskId,
271
+ error: String(error),
272
+ });
273
+ }
227
274
  }
228
275
  return updated;
229
276
  }
@@ -248,7 +295,14 @@ export class TaskManager {
248
295
  throw TaskError.create("INVALID_TASK_STATUS", `Cannot pause task with status: ${task.status}`);
249
296
  }
250
297
  await backend.pause(taskId);
251
- const updated = await store.update(taskId, { status: "paused" });
298
+ let updated;
299
+ try {
300
+ updated = await store.update(taskId, { status: "paused" });
301
+ }
302
+ catch (error) {
303
+ await this.restoreScheduledTask(task, "pause rollback");
304
+ throw error;
305
+ }
252
306
  this.emit("task:paused", updated);
253
307
  return updated;
254
308
  }
@@ -264,7 +318,16 @@ export class TaskManager {
264
318
  throw TaskError.create("INVALID_TASK_STATUS", `Cannot resume task with status: ${task.status}`);
265
319
  }
266
320
  const updated = await store.update(taskId, { status: "active" });
267
- await backend.schedule(updated, (t) => this.onTaskTick(t));
321
+ try {
322
+ await backend.schedule(updated, (t) => this.onTaskTick(t));
323
+ }
324
+ catch (error) {
325
+ await this.rollbackTaskUpdate(taskId, task, error);
326
+ throw TaskError.create("SCHEDULE_FAILED", `Failed to resume task ${taskId}`, {
327
+ cause: error instanceof Error ? error : undefined,
328
+ details: { taskId, schedule: task.schedule },
329
+ });
330
+ }
268
331
  this.emit("task:resumed", updated);
269
332
  return updated;
270
333
  }
@@ -301,6 +364,38 @@ export class TaskManager {
301
364
  return this.backend.isHealthy();
302
365
  }
303
366
  // ── Internal ──────────────────────────────────────────
367
+ async restoreScheduledTask(task, reason) {
368
+ if (task.status !== "active") {
369
+ return;
370
+ }
371
+ try {
372
+ await this.getBackend().schedule(task, (t) => this.onTaskTick(t));
373
+ logger.warn("[TaskManager] Restored task schedule after rollback", {
374
+ taskId: task.id,
375
+ reason,
376
+ });
377
+ }
378
+ catch (restoreError) {
379
+ logger.error("[TaskManager] Failed to restore task schedule during rollback", {
380
+ taskId: task.id,
381
+ reason,
382
+ error: String(restoreError),
383
+ });
384
+ }
385
+ }
386
+ async rollbackTaskUpdate(taskId, previousTask, error) {
387
+ try {
388
+ return await this.getStore().update(taskId, previousTask);
389
+ }
390
+ catch (rollbackError) {
391
+ logger.error("[TaskManager] Failed to roll back task update — store and in-memory state may be diverged; manual reconciliation required", {
392
+ taskId,
393
+ originalError: String(error),
394
+ rollbackError: String(rollbackError),
395
+ });
396
+ throw rollbackError;
397
+ }
398
+ }
304
399
  /**
305
400
  * Called by the backend on each scheduled tick.
306
401
  * Executes the task, updates state, fires callbacks/events.
@@ -54,13 +54,17 @@ export class TelemetryService {
54
54
  if (!provider) {
55
55
  return false;
56
56
  }
57
- const delegateName = provider._delegate?.constructor?.name || "";
58
- if (delegateName && delegateName !== "NoopTracerProvider") {
57
+ const providerName = provider.constructor?.name || "";
58
+ if (providerName &&
59
+ providerName !== "ProxyTracerProvider" &&
60
+ providerName !== "NoopTracerProvider") {
59
61
  return true;
60
62
  }
61
- const providerName = provider.constructor?.name || "";
62
- return (providerName !== "ProxyTracerProvider" &&
63
- providerName !== "NoopTracerProvider");
63
+ const delegate = typeof provider.getDelegate === "function"
64
+ ? provider.getDelegate()
65
+ : provider._delegate;
66
+ const delegateName = delegate?.constructor?.name || "";
67
+ return Boolean(delegateName && delegateName !== "NoopTracerProvider");
64
68
  }
65
69
  catch (error) {
66
70
  logger.warn("[Telemetry] Failed checking for external TracerProvider", {
@@ -13,6 +13,8 @@
13
13
  * - src/lib/proxy/accountQuota.ts (quota type)
14
14
  * - src/lib/server/routes/claudeProxyRoutes.ts (runtime state, deps)
15
15
  */
16
+ import type { Span } from "@opentelemetry/api";
17
+ import type { ProxyTracer } from "../proxy/proxyTracer.js";
16
18
  /**
17
19
  * Type describing the ModelRouter contract.
18
20
  * Defined here to avoid a circular dependency between types and implementation.
@@ -242,7 +244,7 @@ export type ParsedClaudeRequest = {
242
244
  /** Tools translated to AI SDK-compatible shape for provider fallback. */
243
245
  tools: Record<string, {
244
246
  description?: string;
245
- inputSchema?: unknown;
247
+ inputSchema: unknown;
246
248
  execute?: (...args: unknown[]) => unknown;
247
249
  }>;
248
250
  /**
@@ -361,6 +363,14 @@ export type TlsFingerprintOptions = {
361
363
  /** Whether the stub should log a warning that it is a no-op. */
362
364
  warnOnUse?: boolean;
363
365
  };
366
+ /**
367
+ * Proxy operating mode:
368
+ * - "full" — managed accounts, retry, rotation, polyfill (default)
369
+ * - "passthrough" — no polyfill/retry/rotation, but body is still parsed and re-serialized
370
+ * - "transparent" — zero-mutation byte relay: raw body forwarded as-is, minimal header filtering,
371
+ * SSE interceptor for cache metrics only (bytes pass through unmodified)
372
+ */
373
+ export type ProxyMode = "full" | "passthrough" | "transparent";
364
374
  export type RouteResult = {
365
375
  provider: string | null;
366
376
  model: string;
@@ -453,6 +463,119 @@ export type RequestAttemptLogEntry = {
453
463
  /** OTel span ID for correlation with distributed traces */
454
464
  spanId?: string;
455
465
  };
466
+ export type ProxyBodyCaptureInput = {
467
+ phase: string;
468
+ headers?: Record<string, string>;
469
+ body?: unknown;
470
+ bodySize?: number;
471
+ contentType?: string;
472
+ responseStatus?: number;
473
+ durationMs?: number;
474
+ account?: string;
475
+ accountType?: string;
476
+ attempt?: number;
477
+ metadata?: Record<string, unknown>;
478
+ };
479
+ export type ProxyBodyCaptureLogger = (capture: ProxyBodyCaptureInput) => void;
480
+ export type ClaudeFinalRequestLogger = (status: number, accountLabel: string, accountType: string, errorType?: string, errorMessage?: string, extra?: {
481
+ inputTokens?: number;
482
+ outputTokens?: number;
483
+ cacheCreationTokens?: number;
484
+ cacheReadTokens?: number;
485
+ }) => void;
486
+ export type ClaudeLoggedErrorBuilder = (status: number, message: string, errorType?: string, extra?: {
487
+ account?: string;
488
+ accountType?: string;
489
+ attempt?: number;
490
+ }) => ClaudeErrorResponse;
491
+ export type ClaudeRequestRuntimeContext = {
492
+ tracer?: ProxyTracer;
493
+ requestStartTime: number;
494
+ logProxyBody: ProxyBodyCaptureLogger;
495
+ logFinalRequest: ClaudeFinalRequestLogger;
496
+ buildLoggedClaudeError: ClaudeLoggedErrorBuilder;
497
+ };
498
+ export type AnthropicAttemptLogger = (status: number, errorType?: string, errorMessage?: string, extra?: {
499
+ inputTokens?: number;
500
+ outputTokens?: number;
501
+ cacheCreationTokens?: number;
502
+ cacheReadTokens?: number;
503
+ }) => void;
504
+ export type AnthropicLoopState = {
505
+ lastError: unknown;
506
+ sawRateLimit: boolean;
507
+ sawNetworkError: boolean;
508
+ sawTransientFailure: boolean;
509
+ invalidRequestFailure: {
510
+ status: number;
511
+ body: string;
512
+ contentType?: string;
513
+ } | null;
514
+ authFailureMessage: string | null;
515
+ attemptNumber: number;
516
+ };
517
+ export type AnthropicUpstreamBody = {
518
+ bodyStr: string;
519
+ sessionId?: string;
520
+ };
521
+ export type AnthropicUpstreamBodyBuilder = (token: string) => AnthropicUpstreamBody;
522
+ export type LoadedClaudeAccountContext = {
523
+ accounts: ProxyPassthroughAccount[];
524
+ enabledAccounts: ProxyPassthroughAccount[];
525
+ orderedAccounts: ProxyPassthroughAccount[];
526
+ bodyStr: string;
527
+ requestStart: number;
528
+ toolCount: number;
529
+ url: string;
530
+ clientHeaders: Record<string, string | undefined>;
531
+ isClaudeClientRequest: boolean;
532
+ };
533
+ export type AnthropicSuccessResult = {
534
+ retryNextAccount: true;
535
+ } | {
536
+ response: Response | unknown;
537
+ };
538
+ export type AnthropicAuthRetryResult = {
539
+ response?: Response | unknown;
540
+ continueLoop: boolean;
541
+ lastError: unknown;
542
+ authFailureMessage: string | null;
543
+ sawRateLimit: boolean;
544
+ sawTransientFailure: boolean;
545
+ sawNetworkError: boolean;
546
+ upstreamSpan?: Span;
547
+ };
548
+ export type AnthropicNonOkResult = {
549
+ response?: Response | unknown;
550
+ continueLoop: boolean;
551
+ lastError: unknown;
552
+ authFailureMessage: string | null;
553
+ sawTransientFailure: boolean;
554
+ invalidRequestFailure: {
555
+ status: number;
556
+ body: string;
557
+ contentType?: string;
558
+ } | null;
559
+ upstreamSpan?: Span;
560
+ };
561
+ export type PreparedAnthropicAccountAttempt = {
562
+ continueLoop: boolean;
563
+ lastError: unknown;
564
+ authFailureMessage: string | null;
565
+ headers?: Record<string, string>;
566
+ buildUpstreamBody?: AnthropicUpstreamBodyBuilder;
567
+ finalBodyStr?: string;
568
+ fetchStartMs?: number;
569
+ upstreamSpan?: Span;
570
+ };
571
+ export type AnthropicUpstreamFetchResult = {
572
+ continueLoop: boolean;
573
+ response?: Response;
574
+ lastError: unknown;
575
+ sawRateLimit: boolean;
576
+ sawNetworkError: boolean;
577
+ upstreamSpan?: Span;
578
+ };
456
579
  export type AccountStats = {
457
580
  label: string;
458
581
  type: string;
@@ -30,6 +30,7 @@ export declare class ProviderHealthChecker {
30
30
  * Check connectivity to provider endpoints
31
31
  */
32
32
  private static checkConnectivity;
33
+ private static getConnectivityHeaders;
33
34
  /**
34
35
  * Check model availability (if possible without making API calls)
35
36
  */
@@ -72,7 +72,7 @@ export class ProviderHealthChecker {
72
72
  };
73
73
  try {
74
74
  // 1. Check environment configuration
75
- await this.checkEnvironmentConfiguration(providerName, healthStatus);
75
+ await this.checkEnvironmentConfiguration(providerName, healthStatus, timeout);
76
76
  // 2. Check API key validity (basic format validation)
77
77
  await this.checkApiKeyValidity(providerName, healthStatus);
78
78
  // 3. Optional: Connectivity test
@@ -129,7 +129,7 @@ export class ProviderHealthChecker {
129
129
  /**
130
130
  * Check environment configuration for a provider
131
131
  */
132
- static async checkEnvironmentConfiguration(providerName, healthStatus) {
132
+ static async checkEnvironmentConfiguration(providerName, healthStatus, timeout) {
133
133
  const requiredEnvVars = this.getRequiredEnvironmentVariables(providerName);
134
134
  logger.debug(`[ProviderHealthChecker] Checking environment configuration for ${providerName}`, {
135
135
  requiredEnvVars,
@@ -160,7 +160,7 @@ export class ProviderHealthChecker {
160
160
  healthStatus.recommendations.push(`Set the following environment variables: ${missingVars.join(", ")}`);
161
161
  }
162
162
  // Provider-specific configuration checks
163
- await this.checkProviderSpecificConfig(providerName, healthStatus);
163
+ await this.checkProviderSpecificConfig(providerName, healthStatus, timeout);
164
164
  }
165
165
  /**
166
166
  * Check API key validity (format validation)
@@ -261,30 +261,34 @@ export class ProviderHealthChecker {
261
261
  healthStatus.warning = "No connectivity test available for this provider";
262
262
  return;
263
263
  }
264
+ const headers = {
265
+ "User-Agent": "NeuroLink-HealthCheck/1.0",
266
+ ...this.getConnectivityHeaders(providerName),
267
+ };
264
268
  try {
265
269
  const controller = new AbortController();
266
270
  const timeoutId = setTimeout(() => controller.abort(), timeout);
267
- const proxyFetch = createProxyFetch();
268
- let response = await proxyFetch(endpoint, {
269
- method: "HEAD",
270
- signal: controller.signal,
271
- headers: {
272
- "User-Agent": "NeuroLink-HealthCheck/1.0",
273
- },
274
- });
275
- // Fallback to GET if HEAD returns 405 (Method Not Allowed) for restrictive gateways
276
- if (response.status === 405) {
277
- response = await proxyFetch(endpoint, {
278
- method: "GET",
271
+ try {
272
+ const proxyFetch = createProxyFetch();
273
+ let response = await proxyFetch(endpoint, {
274
+ method: "HEAD",
279
275
  signal: controller.signal,
280
- headers: {
281
- "User-Agent": "NeuroLink-HealthCheck/1.0",
282
- },
276
+ headers,
283
277
  });
278
+ // Fallback to GET if HEAD returns 405 (Method Not Allowed) for restrictive gateways
279
+ if (response.status === 405) {
280
+ response = await proxyFetch(endpoint, {
281
+ method: "GET",
282
+ signal: controller.signal,
283
+ headers,
284
+ });
285
+ }
286
+ if (!response.ok) {
287
+ healthStatus.configurationIssues.push(`Connectivity test failed: HTTP ${response.status}`);
288
+ }
284
289
  }
285
- clearTimeout(timeoutId);
286
- if (!response.ok) {
287
- healthStatus.configurationIssues.push(`Connectivity test failed: HTTP ${response.status}`);
290
+ finally {
291
+ clearTimeout(timeoutId);
288
292
  }
289
293
  }
290
294
  catch (error) {
@@ -320,6 +324,14 @@ export class ProviderHealthChecker {
320
324
  }
321
325
  }
322
326
  }
327
+ static getConnectivityHeaders(providerName) {
328
+ if (providerName === AIProviderName.LITELLM) {
329
+ return {
330
+ Authorization: `Bearer ${process.env.LITELLM_API_KEY || "sk-anything"}`,
331
+ };
332
+ }
333
+ return {};
334
+ }
323
335
  /**
324
336
  * Check model availability (if possible without making API calls)
325
337
  */
@@ -455,7 +467,7 @@ export class ProviderHealthChecker {
455
467
  /**
456
468
  * Provider-specific configuration checks
457
469
  */
458
- static async checkProviderSpecificConfig(providerName, healthStatus) {
470
+ static async checkProviderSpecificConfig(providerName, healthStatus, timeout) {
459
471
  switch (providerName) {
460
472
  case AIProviderName.VERTEX:
461
473
  await this.checkVertexAIConfig(healthStatus);
@@ -467,10 +479,10 @@ export class ProviderHealthChecker {
467
479
  await this.checkAzureConfig(healthStatus);
468
480
  break;
469
481
  case AIProviderName.LITELLM:
470
- await this.checkLiteLLMConfig(healthStatus);
482
+ await this.checkLiteLLMConfig(healthStatus, timeout);
471
483
  break;
472
484
  case AIProviderName.OLLAMA:
473
- await this.checkOllamaConfig(healthStatus);
485
+ await this.checkOllamaConfig(healthStatus, timeout);
474
486
  break;
475
487
  }
476
488
  }
@@ -722,9 +734,12 @@ export class ProviderHealthChecker {
722
734
  .filter((model) => typeof model === "string");
723
735
  }
724
736
  static hasRequestedModel(availableModels, requestedModel) {
725
- return availableModels.some((model) => model === requestedModel ||
726
- model.startsWith(`${requestedModel}:`) ||
727
- requestedModel.startsWith(`${model}:`));
737
+ const normalizedRequestedModel = requestedModel.trim();
738
+ const requiresExactMatch = /@/.test(normalizedRequestedModel);
739
+ return availableModels.some((model) => model === normalizedRequestedModel ||
740
+ (!requiresExactMatch &&
741
+ (model.startsWith(`${normalizedRequestedModel}:`) ||
742
+ model.startsWith(`${normalizedRequestedModel}@`))));
728
743
  }
729
744
  static async getOllamaAvailableModels(timeout = 2000) {
730
745
  const payload = (await this.fetchJsonWithTimeout(this.getOllamaTagsUrl(), {
@@ -789,7 +804,7 @@ export class ProviderHealthChecker {
789
804
  };
790
805
  }
791
806
  }
792
- static async checkLiteLLMConfig(healthStatus) {
807
+ static async checkLiteLLMConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT) {
793
808
  const liteLLMBase = this.getLiteLLMBaseUrl();
794
809
  if (!liteLLMBase.startsWith("http")) {
795
810
  healthStatus.isConfigured = false;
@@ -799,7 +814,7 @@ export class ProviderHealthChecker {
799
814
  }
800
815
  const availability = await this.checkLiteLLMAvailability({
801
816
  model: this.getConfiguredLiteLLMModel(),
802
- timeout: 2000,
817
+ timeout,
803
818
  });
804
819
  if (!availability.available) {
805
820
  healthStatus.isConfigured = false;
@@ -812,7 +827,7 @@ export class ProviderHealthChecker {
812
827
  /**
813
828
  * Check Ollama configuration
814
829
  */
815
- static async checkOllamaConfig(healthStatus) {
830
+ static async checkOllamaConfig(healthStatus, timeout = this.DEFAULT_TIMEOUT) {
816
831
  const ollamaBase = this.getOllamaBaseUrl();
817
832
  if (!ollamaBase.startsWith("http")) {
818
833
  healthStatus.isConfigured = false;
@@ -822,7 +837,7 @@ export class ProviderHealthChecker {
822
837
  }
823
838
  const availability = await this.checkOllamaAvailability({
824
839
  model: this.getConfiguredOllamaModel(),
825
- timeout: 2000,
840
+ timeout,
826
841
  });
827
842
  if (!availability.available) {
828
843
  healthStatus.isConfigured = false;
@@ -50,7 +50,8 @@ export async function getBestProvider(requestedProvider) {
50
50
  return process.env.DEFAULT_PROVIDER;
51
51
  }
52
52
  // Special case for Ollama - prioritize local when available
53
- if (process.env.OLLAMA_BASE_URL && process.env.OLLAMA_MODEL) {
53
+ if ((process.env.OLLAMA_BASE_URL || process.env.OLLAMA_API_BASE) &&
54
+ process.env.OLLAMA_MODEL) {
54
55
  try {
55
56
  if (await isProviderAvailable("ollama")) {
56
57
  logger.debug(`[getBestProvider] Prioritizing working local Ollama`);
@@ -64,7 +65,7 @@ export async function getBestProvider(requestedProvider) {
64
65
  /**
65
66
  * Provider priority order rationale:
66
67
  * - LiteLLM and Ollama are prioritized first for local/self-hosted deployments,
67
- * avoiding cloud quota/rate-limit issues during fallback scenarios.
68
+ * avoiding unnecessary dependence on external providers during fallback scenarios.
68
69
  * - Vertex (Google Cloud AI) follows for enterprise-grade reliability.
69
70
  * - Google AI follows as second cloud priority for comprehensive Google AI ecosystem support.
70
71
  * - OpenAI maintains high priority due to its consistent reliability and broad model support.
@@ -72,8 +73,8 @@ export async function getBestProvider(requestedProvider) {
72
73
  * Please update this comment if the order is changed in the future, and document the rationale for maintainability.
73
74
  */
74
75
  const providers = [
75
- "litellm", // Prioritize self-hosted/proxy (no rate limits)
76
- "ollama", // Local models (no rate limits)
76
+ "litellm", // Prioritize self-hosted proxy deployments first
77
+ "ollama", // Local models when the configured runtime target is installed
77
78
  "vertex", // Google Cloud AI (enterprise)
78
79
  "google-ai", // Google AI ecosystem support
79
80
  "openai", // Reliable with broad model support
@@ -101,25 +102,13 @@ async function isProviderAvailable(providerName) {
101
102
  if (!hasProviderEnvVars(providerName) && providerName !== "ollama") {
102
103
  return false;
103
104
  }
105
+ if (providerName === "litellm") {
106
+ const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(AIProviderName.LITELLM, process.env.LITELLM_MODEL || "openai/gpt-4o-mini");
107
+ return availability.available;
108
+ }
104
109
  if (providerName === "ollama") {
105
- try {
106
- const response = await fetch("http://localhost:11434/api/tags", {
107
- method: "GET",
108
- signal: AbortSignal.timeout(2000),
109
- });
110
- if (response.ok) {
111
- const { models } = await response.json();
112
- const defaultOllamaModel = process.env.OLLAMA_MODEL || "llama3.1:8b";
113
- // Check for exact match first, then prefix match (e.g. "gemma3:27b" matches "gemma3:27b-fp16")
114
- return models.some((m) => m.name === defaultOllamaModel ||
115
- (typeof m.name === "string" &&
116
- m.name.startsWith(defaultOllamaModel.split(":")[0] + ":")));
117
- }
118
- return false;
119
- }
120
- catch {
121
- return false;
122
- }
110
+ const availability = await ProviderHealthChecker.checkFallbackProviderAvailability(AIProviderName.OLLAMA, process.env.OLLAMA_MODEL || "llama3.1:8b");
111
+ return availability.available;
123
112
  }
124
113
  try {
125
114
  const provider = await AIProviderFactory.createProvider(providerName);
@@ -69,6 +69,8 @@ export declare class MCPToolRegistry extends MCPRegistry {
69
69
  permissions?: string[];
70
70
  context?: ExecutionContext;
71
71
  }): Promise<ToolInfo[]>;
72
+ private resolveToolExecutionTarget;
73
+ private createExecutionContext;
72
74
  /**
73
75
  * Get tool information with server details
74
76
  */
@@ -9,6 +9,7 @@ import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
9
9
  import { directAgentTools } from "../agent/directTools.js";
10
10
  import { detectCategory, createMCPServerInfo } from "../utils/mcpDefaults.js";
11
11
  import { FlexibleToolValidator } from "./flexibleToolValidator.js";
12
+ import { ErrorFactory } from "../utils/errorHandling.js";
12
13
  import { HITLUserRejectedError, HITLTimeoutError } from "../hitl/hitlErrors.js";
13
14
  import { withSpan, tracers, ATTR } from "../telemetry/index.js";
14
15
  import { getAuthContext } from "../auth/authContext.js";
@@ -263,22 +264,7 @@ export class MCPToolRegistry extends MCPRegistry {
263
264
  hasContext: context !== undefined,
264
265
  sessionId: context?.sessionId,
265
266
  });
266
- // Try to find the tool by fully-qualified name first
267
- let tool = this.tools.get(toolName);
268
- registryLogger.info(`🔍 [TOOL_LOOKUP] Direct lookup result for '${toolName}':`, !!tool);
269
- // If not found, search for tool by name across all entries (for backward compatibility)
270
- let toolId = toolName;
271
- if (!tool) {
272
- const matches = Array.from(this.tools.entries()).filter(([, toolInfo]) => toolInfo.name === toolName);
273
- if (matches.length > 1) {
274
- throw new Error(`Ambiguous tool name '${toolName}'. Use fully-qualified name 'serverId.${toolName}'.`);
275
- }
276
- if (matches.length === 1) {
277
- const [candidateToolId, toolInfo] = matches[0];
278
- tool = toolInfo;
279
- toolId = candidateToolId;
280
- }
281
- }
267
+ const { tool, toolId } = this.resolveToolExecutionTarget(toolName);
282
268
  if (!tool) {
283
269
  throw new Error(`Tool '${toolName}' not found in registry`);
284
270
  }
@@ -291,21 +277,7 @@ export class MCPToolRegistry extends MCPRegistry {
291
277
  : "mcp";
292
278
  span.setAttribute("tool.type", toolType);
293
279
  span.setAttribute(ATTR.MCP_SERVER_ID, serverId);
294
- // Try to get auth context if available
295
- let authUserId;
296
- try {
297
- const authCtx = getAuthContext();
298
- authUserId = authCtx?.user?.id;
299
- }
300
- catch {
301
- // Auth context not available — that's fine
302
- }
303
- // Create execution context if not provided
304
- const execContext = {
305
- ...context,
306
- sessionId: context?.sessionId ?? randomUUID(),
307
- userId: context?.userId ?? authUserId,
308
- };
280
+ const execContext = this.createExecutionContext(context);
309
281
  // Get the tool implementation using the resolved toolId
310
282
  const toolImpl = this.toolImplementations.get(toolId);
311
283
  registryLogger.debug(`Looking for tool '${toolName}' (toolId: '${toolId}'), found: ${!!toolImpl}, type: ${typeof toolImpl?.execute}`);
@@ -504,6 +476,35 @@ export class MCPToolRegistry extends MCPRegistry {
504
476
  registryLogger.debug(`Listed ${result.length} unique tools (${filter ? "filtered" : "unfiltered"})`);
505
477
  return result;
506
478
  }
479
+ resolveToolExecutionTarget(toolName) {
480
+ let tool = this.tools.get(toolName);
481
+ registryLogger.info(`🔍 [TOOL_LOOKUP] Direct lookup result for '${toolName}':`, !!tool);
482
+ let toolId = toolName;
483
+ if (!tool) {
484
+ const matches = Array.from(this.tools.entries()).filter(([, toolInfo]) => toolInfo.name === toolName);
485
+ if (matches.length > 1) {
486
+ throw ErrorFactory.toolExecutionFailed(toolName, new Error(`Ambiguous tool name '${toolName}'. Use fully-qualified name 'serverId.${toolName}'.`));
487
+ }
488
+ if (matches.length === 1) {
489
+ [toolId, tool] = matches[0];
490
+ }
491
+ }
492
+ return { tool, toolId };
493
+ }
494
+ createExecutionContext(context) {
495
+ let authUserId;
496
+ try {
497
+ authUserId = getAuthContext()?.user?.id;
498
+ }
499
+ catch {
500
+ // Auth context not available — that's fine
501
+ }
502
+ return {
503
+ ...context,
504
+ sessionId: context?.sessionId ?? randomUUID(),
505
+ userId: context?.userId ?? authUserId,
506
+ };
507
+ }
507
508
  /**
508
509
  * Get tool information with server details
509
510
  */