@juspay/neurolink 11.0.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/browser/neurolink.min.js +524 -524
  3. package/dist/cli/commands/setup.d.ts +4 -0
  4. package/dist/cli/commands/setup.js +19 -33
  5. package/dist/cli/factories/commandFactory.d.ts +1157 -1
  6. package/dist/cli/factories/commandFactory.js +20 -53
  7. package/dist/factories/providerDescriptors.d.ts +18 -0
  8. package/dist/factories/providerDescriptors.js +536 -0
  9. package/dist/factories/providerFactory.d.ts +25 -14
  10. package/dist/factories/providerFactory.js +46 -23
  11. package/dist/factories/providerRegistry.js +31 -30
  12. package/dist/index.d.ts +10 -1
  13. package/dist/index.js +13 -3
  14. package/dist/lib/factories/providerDescriptors.d.ts +18 -0
  15. package/dist/lib/factories/providerDescriptors.js +537 -0
  16. package/dist/lib/factories/providerFactory.d.ts +25 -14
  17. package/dist/lib/factories/providerFactory.js +46 -23
  18. package/dist/lib/factories/providerRegistry.js +31 -30
  19. package/dist/lib/index.d.ts +10 -1
  20. package/dist/lib/index.js +13 -3
  21. package/dist/lib/neurolink.js +25 -37
  22. package/dist/lib/providers/amazonBedrock/client.js +34 -16
  23. package/dist/lib/providers/anthropic/client.js +39 -34
  24. package/dist/lib/providers/azureOpenai.js +17 -15
  25. package/dist/lib/providers/cloudflare.js +12 -21
  26. package/dist/lib/providers/cohere.js +31 -25
  27. package/dist/lib/providers/deepseek.js +23 -26
  28. package/dist/lib/providers/fireworks.js +12 -21
  29. package/dist/lib/providers/googleAiStudio/client.d.ts +2 -0
  30. package/dist/lib/providers/googleAiStudio/client.js +39 -17
  31. package/dist/lib/providers/googleVertex/client.js +107 -99
  32. package/dist/lib/providers/groq.js +19 -22
  33. package/dist/lib/providers/huggingFace/client.js +26 -24
  34. package/dist/lib/providers/litellm/client.js +54 -39
  35. package/dist/lib/providers/llamaCpp.js +21 -18
  36. package/dist/lib/providers/lmStudio.js +22 -19
  37. package/dist/lib/providers/mistral.js +12 -24
  38. package/dist/lib/providers/nvidiaNim/client.js +37 -34
  39. package/dist/lib/providers/ollama/client.js +50 -35
  40. package/dist/lib/providers/openAI/client.js +37 -40
  41. package/dist/lib/providers/openRouter/client.js +53 -47
  42. package/dist/lib/providers/openaiChatCompletionsBase.js +40 -16
  43. package/dist/lib/providers/openaiCompatible/client.js +36 -32
  44. package/dist/lib/providers/perplexity.js +12 -21
  45. package/dist/lib/providers/togetherAi.js +12 -21
  46. package/dist/lib/providers/xai.js +17 -26
  47. package/dist/lib/server/errors.d.ts +1 -1
  48. package/dist/lib/server/errors.js +2 -2
  49. package/dist/lib/server/index.d.ts +2 -2
  50. package/dist/lib/server/index.js +5 -3
  51. package/dist/lib/server/middleware/rateLimit.d.ts +0 -4
  52. package/dist/lib/server/middleware/rateLimit.js +0 -4
  53. package/dist/lib/types/errors.d.ts +35 -0
  54. package/dist/lib/types/providers.d.ts +64 -0
  55. package/dist/lib/utils/errorClassifier.d.ts +30 -0
  56. package/dist/lib/utils/errorClassifier.js +94 -0
  57. package/dist/lib/utils/fileDetector.js +6 -43
  58. package/dist/lib/utils/providerHealth.d.ts +42 -7
  59. package/dist/lib/utils/providerHealth.js +115 -122
  60. package/dist/lib/utils/providerUtils.js +21 -63
  61. package/dist/neurolink.js +25 -37
  62. package/dist/providers/amazonBedrock/client.js +34 -16
  63. package/dist/providers/amazonSagemaker.d.ts +1 -1
  64. package/dist/providers/anthropic/client.js +39 -34
  65. package/dist/providers/azureOpenai.js +17 -15
  66. package/dist/providers/cloudflare.js +12 -21
  67. package/dist/providers/cohere.js +31 -25
  68. package/dist/providers/deepseek.js +23 -26
  69. package/dist/providers/fireworks.js +12 -21
  70. package/dist/providers/googleAiStudio/client.d.ts +2 -0
  71. package/dist/providers/googleAiStudio/client.js +39 -17
  72. package/dist/providers/googleVertex/client.js +107 -99
  73. package/dist/providers/groq.js +19 -22
  74. package/dist/providers/huggingFace/client.js +26 -24
  75. package/dist/providers/litellm/client.js +54 -39
  76. package/dist/providers/llamaCpp.js +21 -18
  77. package/dist/providers/lmStudio.js +22 -19
  78. package/dist/providers/mistral.js +12 -24
  79. package/dist/providers/nvidiaNim/client.js +37 -34
  80. package/dist/providers/ollama/client.js +50 -35
  81. package/dist/providers/openAI/client.js +37 -40
  82. package/dist/providers/openRouter/client.js +53 -47
  83. package/dist/providers/openaiChatCompletionsBase.js +40 -16
  84. package/dist/providers/openaiCompatible/client.js +36 -32
  85. package/dist/providers/perplexity.js +12 -21
  86. package/dist/providers/sagemaker/language-model.d.ts +2 -2
  87. package/dist/providers/togetherAi.js +12 -21
  88. package/dist/providers/xai.js +17 -26
  89. package/dist/server/errors.d.ts +1 -1
  90. package/dist/server/errors.js +2 -2
  91. package/dist/server/index.d.ts +2 -2
  92. package/dist/server/index.js +5 -3
  93. package/dist/server/middleware/rateLimit.d.ts +0 -4
  94. package/dist/server/middleware/rateLimit.js +0 -4
  95. package/dist/types/errors.d.ts +35 -0
  96. package/dist/types/providers.d.ts +64 -0
  97. package/dist/utils/errorClassifier.d.ts +30 -0
  98. package/dist/utils/errorClassifier.js +93 -0
  99. package/dist/utils/fileDetector.js +6 -43
  100. package/dist/utils/providerHealth.d.ts +42 -7
  101. package/dist/utils/providerHealth.js +115 -122
  102. package/dist/utils/providerUtils.js +21 -63
  103. package/package.json +15 -70
@@ -2,13 +2,14 @@ import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  import { registerRuntimeContextWindow, registerRuntimeOutputCeiling, } from "../../constants/contextWindows.js";
3
3
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
4
4
  import { AuthenticationError, InvalidModelError, ModelAccessDeniedError, NetworkError, ProviderError, RateLimitError, isModelAccessDeniedMessage, parseAllowedModels, } from "../../types/index.js";
5
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
5
6
  import { isAbortError } from "../../utils/errorHandling.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
8
9
  import { isGemini25Model as isCanonicalGemini25Model } from "../../utils/modelDetection.js";
9
10
  import { calculateCost } from "../../utils/pricing.js";
10
11
  import { getProviderModel } from "../../utils/providerConfig.js";
11
- import { createTimeoutController, TimeoutError } from "../../utils/timeout.js";
12
+ import { createTimeoutController } from "../../utils/timeout.js";
12
13
  import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
13
14
  import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
14
15
  const streamTracer = trace.getTracer("neurolink.provider.litellm");
@@ -320,46 +321,60 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
320
321
  };
321
322
  }
322
323
  formatProviderError(error) {
323
- if (error instanceof TimeoutError) {
324
- return new NetworkError(`Request timed out: ${error.message}`, this.providerName);
325
- }
324
+ // Curator P1-1: detect "team not allowed to access model" responses and
325
+ // surface as ModelAccessDeniedError with the allowed_models array parsed
326
+ // from the body. Must run before classification (not just before the
327
+ // "API key" rule) because ModelAccessDeniedError's constructor takes an
328
+ // `{ provider, requestedModel, allowedModels }` options object rather
329
+ // than the `(message, provider?)` shape ProviderErrorRule expects, so it
330
+ // can't be expressed as a declarative rule. No realistic overlap with the
331
+ // timeout/ECONNREFUSED checks below (disjoint wording), so running this
332
+ // first is behaviorally identical to the original nesting order.
326
333
  const errorRecord = error;
327
- if (errorRecord?.name === "TimeoutError" ||
328
- (typeof errorRecord?.message === "string" &&
329
- errorRecord.message.toLowerCase().includes("timeout"))) {
330
- return new NetworkError(`Request timed out: ${errorRecord?.message || "Unknown timeout"}`, this.providerName);
331
- }
332
- if (typeof errorRecord?.message === "string") {
333
- if (errorRecord.message.includes("ECONNREFUSED") ||
334
- errorRecord.message.includes("Failed to fetch")) {
335
- return new NetworkError("LiteLLM proxy server not available. Please start the LiteLLM proxy server at " +
336
- `${process.env.LITELLM_BASE_URL || "http://localhost:4000"}`, this.providerName);
337
- }
338
- // Curator P1-1: detect "team not allowed to access model" responses and
339
- // surface as ModelAccessDeniedError with the allowed_models array parsed
340
- // from the body. Must run before the generic "API key" check because
341
- // LiteLLM phrases this as a 403 distinct from auth.
342
- if (isModelAccessDeniedMessage(errorRecord.message)) {
343
- return new ModelAccessDeniedError(errorRecord.message, {
344
- provider: this.providerName,
345
- requestedModel: this.modelName,
346
- allowedModels: parseAllowedModels(errorRecord.message),
347
- });
348
- }
349
- if (errorRecord.message.includes("API_KEY_INVALID") ||
350
- errorRecord.message.includes("Invalid API key")) {
351
- return new AuthenticationError("Invalid LiteLLM configuration. Please check your LITELLM_API_KEY environment variable.", this.providerName);
352
- }
353
- if (errorRecord.message.toLowerCase().includes("rate limit")) {
354
- return new RateLimitError("LiteLLM rate limit exceeded. Please try again later.", this.providerName);
355
- }
356
- if (errorRecord.message.toLowerCase().includes("model") &&
357
- errorRecord.message.toLowerCase().includes("not found")) {
358
- return new InvalidModelError(`Model '${this.modelName}' not available in LiteLLM proxy. ` +
359
- "Please check your LiteLLM configuration and ensure the model is configured.", this.providerName);
360
- }
334
+ if (typeof errorRecord?.message === "string" &&
335
+ isModelAccessDeniedMessage(errorRecord.message)) {
336
+ return new ModelAccessDeniedError(errorRecord.message, {
337
+ provider: this.providerName,
338
+ requestedModel: this.modelName,
339
+ allowedModels: parseAllowedModels(errorRecord.message),
340
+ });
361
341
  }
362
- return new ProviderError(`LiteLLM error: ${errorRecord?.message || "Unknown error"}`, this.providerName);
342
+ const rules = [
343
+ // Duck-typed timeout detection (name === "TimeoutError" OR message
344
+ // contains "timeout") distinct from the `instanceof TimeoutError` check
345
+ // classifyProviderError already performs first — preserved because
346
+ // some rejection paths produce a plain object/Error with that shape
347
+ // rather than a real TimeoutError instance.
348
+ {
349
+ match: (ctx) => ctx.errorName === "TimeoutError" || /timeout/i.test(ctx.message),
350
+ errorClass: NetworkError,
351
+ message: (ctx) => `Request timed out: ${ctx.message}`,
352
+ },
353
+ {
354
+ match: (ctx) => /ECONNREFUSED|Failed to fetch/.test(ctx.message),
355
+ errorClass: NetworkError,
356
+ message: () => "LiteLLM proxy server not available. Please start the LiteLLM proxy server at " +
357
+ `${process.env.LITELLM_BASE_URL || "http://localhost:4000"}`,
358
+ },
359
+ {
360
+ match: (ctx) => /API_KEY_INVALID|Invalid API key/.test(ctx.message),
361
+ errorClass: AuthenticationError,
362
+ message: "Invalid LiteLLM configuration. Please check your LITELLM_API_KEY environment variable.",
363
+ },
364
+ {
365
+ match: (ctx) => /rate limit/i.test(ctx.message),
366
+ errorClass: RateLimitError,
367
+ message: "LiteLLM rate limit exceeded. Please try again later.",
368
+ },
369
+ {
370
+ match: (ctx) => /model/i.test(ctx.message) && /not found/i.test(ctx.message),
371
+ errorClass: InvalidModelError,
372
+ message: () => `Model '${this.modelName}' not available in LiteLLM proxy. ` +
373
+ "Please check your LiteLLM configuration and ensure the model is configured.",
374
+ },
375
+ ...DEFAULT_ERROR_RULES,
376
+ ];
377
+ return classifyProviderError(error, rules, this.providerName, this.modelName);
363
378
  }
364
379
  /**
365
380
  * Get available models from LiteLLM proxy `/v1/models` endpoint.
@@ -1,7 +1,7 @@
1
1
  import { NetworkError, ProviderError } from "../types/index.js";
2
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../utils/errorClassifier.js";
2
3
  import { logger } from "../utils/logger.js";
3
4
  import { redactUrlCredentials } from "../utils/logSanitize.js";
4
- import { TimeoutError } from "../utils/timeout.js";
5
5
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
6
6
  const LLAMACPP_DEFAULT_BASE_URL = "http://localhost:8080/v1";
7
7
  const LLAMACPP_PLACEHOLDER_KEY = "llamacpp";
@@ -52,25 +52,28 @@ export class LlamaCppProvider extends OpenAIChatCompletionsProvider {
52
52
  return ["loaded-model"];
53
53
  }
54
54
  formatProviderError(error) {
55
- if (error instanceof TimeoutError) {
56
- return new NetworkError(`Request timed out: ${error.message}`, "llamacpp");
57
- }
55
+ // `code`/`cause.code` aren't part of ProviderErrorContext, so they're read
56
+ // off the raw error here (mirrors ollama's `responseBody` extraction) for
57
+ // the ECONNREFUSED rule below, which the pre-migration code also checked
58
+ // via a duck-typed error code in addition to the message text.
58
59
  const errorRecord = error;
59
- const message = typeof errorRecord?.message === "string"
60
- ? errorRecord.message
61
- : "Unknown error";
62
60
  const cause = errorRecord?.cause ?? {};
63
61
  const code = (errorRecord?.code ?? cause?.code);
64
- if (code === "ECONNREFUSED" ||
65
- message.includes("ECONNREFUSED") ||
66
- message.includes("Failed to fetch") ||
67
- message.includes("fetch failed")) {
68
- return new NetworkError(`llama.cpp server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
69
- "Start it with: ./llama-server -m model.gguf --port 8080", "llamacpp");
70
- }
71
- if (message.includes("400")) {
72
- return new ProviderError("llama.cpp rejected the request. Common cause: model doesn't support tools (start llama-server with --jinja for tool support).", "llamacpp");
73
- }
74
- return new ProviderError(`llama.cpp error: ${message}`, "llamacpp");
62
+ const rules = [
63
+ {
64
+ match: (ctx) => code === "ECONNREFUSED" ||
65
+ /ECONNREFUSED|Failed to fetch|fetch failed/.test(ctx.message),
66
+ errorClass: NetworkError,
67
+ message: () => `llama.cpp server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
68
+ "Start it with: ./llama-server -m model.gguf --port 8080",
69
+ },
70
+ {
71
+ match: (ctx) => /400/.test(ctx.message),
72
+ errorClass: ProviderError,
73
+ message: "llama.cpp rejected the request. Common cause: model doesn't support tools (start llama-server with --jinja for tool support).",
74
+ },
75
+ ...DEFAULT_ERROR_RULES,
76
+ ];
77
+ return classifyProviderError(error, rules, "llamacpp", this.modelName);
75
78
  }
76
79
  }
@@ -1,7 +1,7 @@
1
- import { InvalidModelError, NetworkError, ProviderError, } from "../types/index.js";
1
+ import { InvalidModelError, NetworkError } from "../types/index.js";
2
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../utils/errorClassifier.js";
2
3
  import { logger } from "../utils/logger.js";
3
4
  import { redactUrlCredentials } from "../utils/logSanitize.js";
4
- import { TimeoutError } from "../utils/timeout.js";
5
5
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
6
6
  const LM_STUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1";
7
7
  const LM_STUDIO_PLACEHOLDER_KEY = "lm-studio";
@@ -47,26 +47,29 @@ export class LMStudioProvider extends OpenAIChatCompletionsProvider {
47
47
  return FALLBACK_MODEL;
48
48
  }
49
49
  formatProviderError(error) {
50
- if (error instanceof TimeoutError) {
51
- return new NetworkError(`Request timed out: ${error.message}`, "lm-studio");
52
- }
50
+ // `code`/`cause.code` aren't part of ProviderErrorContext, so they're read
51
+ // off the raw error here (mirrors ollama's `responseBody` extraction) for
52
+ // the ECONNREFUSED rule below, which the pre-migration code also checked
53
+ // via a duck-typed error code in addition to the message text.
53
54
  const errorRecord = error;
54
- const message = typeof errorRecord?.message === "string"
55
- ? errorRecord.message
56
- : "Unknown error";
57
55
  const cause = errorRecord?.cause ?? {};
58
56
  const code = (errorRecord?.code ?? cause?.code);
59
- if (code === "ECONNREFUSED" ||
60
- message.includes("ECONNREFUSED") ||
61
- message.includes("Failed to fetch") ||
62
- message.includes("fetch failed")) {
63
- return new NetworkError(`LM Studio server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
64
- `Open the LM Studio app, load a model, and click "Start Server".`, "lm-studio");
65
- }
66
- if (message.includes("model_not_found") || message.includes("404")) {
67
- return new InvalidModelError(`LM Studio model '${this.modelName}' is not loaded. Load it in the LM Studio app first.`, "lm-studio");
68
- }
69
- return new ProviderError(`LM Studio error: ${message}`, "lm-studio");
57
+ const rules = [
58
+ {
59
+ match: (ctx) => code === "ECONNREFUSED" ||
60
+ /ECONNREFUSED|Failed to fetch|fetch failed/.test(ctx.message),
61
+ errorClass: NetworkError,
62
+ message: () => `LM Studio server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
63
+ `Open the LM Studio app, load a model, and click "Start Server".`,
64
+ },
65
+ {
66
+ match: (ctx) => /model_not_found|404/.test(ctx.message),
67
+ errorClass: InvalidModelError,
68
+ message: () => `LM Studio model '${this.modelName}' is not loaded. Load it in the LM Studio app first.`,
69
+ },
70
+ ...DEFAULT_ERROR_RULES,
71
+ ];
72
+ return classifyProviderError(error, rules, "lm-studio", this.modelName);
70
73
  }
71
74
  async validateConfiguration() {
72
75
  return this.probeModelsEndpoint(this.config.apiKey && this.config.apiKey !== LM_STUDIO_PLACEHOLDER_KEY
@@ -1,9 +1,9 @@
1
1
  import { MistralModels } from "../constants/enums.js";
2
- import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
2
+ import { AuthenticationError } from "../types/index.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { redactUrlCredentials } from "../utils/logSanitize.js";
5
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../utils/errorClassifier.js";
5
6
  import { createMistralConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
6
- import { TimeoutError } from "../utils/timeout.js";
7
7
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
8
8
  const MISTRAL_DEFAULT_BASE_URL = "https://api.mistral.ai/v1";
9
9
  const getMistralApiKey = () => {
@@ -60,28 +60,16 @@ export class MistralProvider extends OpenAIChatCompletionsProvider {
60
60
  return getDefaultMistralModel();
61
61
  }
62
62
  formatProviderError(error) {
63
- if (error instanceof TimeoutError) {
64
- return new NetworkError(`Request timed out: ${error.message}`, "mistral");
65
- }
66
- const errorRecord = error;
67
- const message = typeof errorRecord?.message === "string"
68
- ? errorRecord.message
69
- : "Unknown error";
70
- if (message.includes("API_KEY_INVALID") ||
71
- message.includes("Invalid API key") ||
72
- message.includes("Unauthorized") ||
73
- message.includes("401")) {
74
- return new AuthenticationError("Invalid Mistral API key. Please check your MISTRAL_API_KEY environment variable.", "mistral");
75
- }
76
- if (message.includes("rate limit") ||
77
- message.includes("Rate limit") ||
78
- message.includes("429")) {
79
- return new RateLimitError("Mistral rate limit exceeded", "mistral");
80
- }
81
- if (message.includes("model_not_found") || message.includes("404")) {
82
- return new InvalidModelError(`Mistral model '${this.modelName}' not found.`, "mistral");
83
- }
84
- return new ProviderError(`Mistral error: ${message}`, "mistral");
63
+ const rules = [
64
+ {
65
+ match: (ctx) => ctx.statusCode === 401 ||
66
+ /API_KEY_INVALID|Invalid API key|Unauthorized/i.test(ctx.message),
67
+ errorClass: AuthenticationError,
68
+ message: "Invalid Mistral API key. Please check your MISTRAL_API_KEY environment variable.",
69
+ },
70
+ ...DEFAULT_ERROR_RULES,
71
+ ];
72
+ return classifyProviderError(error, rules, "mistral", this.modelName);
85
73
  }
86
74
  // ===========================================================================
87
75
  // Optional hooks
@@ -1,9 +1,9 @@
1
1
  import { NvidiaNimModels } from "../../constants/enums.js";
2
- import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
2
+ import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../../types/index.js";
3
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
3
4
  import { logger } from "../../utils/logger.js";
4
5
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
5
6
  import { createNvidiaNimConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
6
- import { TimeoutError } from "../../utils/timeout.js";
7
7
  import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
8
8
  /**
9
9
  * Decide whether a NIM 400 response body is a rejection of the named
@@ -237,38 +237,41 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
237
237
  return JSON.parse(serialized);
238
238
  }
239
239
  formatProviderError(error) {
240
- if (error instanceof TimeoutError) {
241
- return new NetworkError(`Request timed out: ${error.message}`, "nvidia-nim");
242
- }
243
- const errorRecord = error;
244
- const message = typeof errorRecord?.message === "string"
245
- ? errorRecord.message
246
- : "Unknown error";
247
- // NIM canonically returns HTTP 401/Unauthorized for invalid API keys,
248
- // but its OpenAI-compatible gateway sometimes surfaces a bare 400 +
249
- // "Bad Request" with no body details for both malformed-credentials
250
- // and bad-parameter cases. Because the two are indistinguishable from
251
- // the message alone, we DON'T promote bare 400/Bad Request to "invalid
252
- // key" here — that would mis-classify legitimate parameter errors
253
- // (e.g. unsupported `reasoning_budget`, unsupported `chat_template`)
254
- // as auth failures. Tests that probe the auth path (K1) detect
255
- // "bad request" / "400" themselves; tests that probe parameter retry
256
- // (K5) need the original "Bad Request" message to surface.
257
- if (message.includes("Invalid API key") ||
258
- message.includes("401") ||
259
- message.includes("Unauthorized")) {
260
- return new AuthenticationError("Invalid NVIDIA NIM API key. Get one at https://build.nvidia.com/settings/api-keys", "nvidia-nim");
261
- }
262
- if (message.includes("rate limit") || message.includes("429")) {
263
- return new RateLimitError("NVIDIA NIM rate limit exceeded", "nvidia-nim");
264
- }
265
- if (message.includes("404") || message.includes("model_not_found")) {
266
- return new InvalidModelError(`NVIDIA NIM model '${this.modelName}' not available. Browse the catalog at https://build.nvidia.com/models`, "nvidia-nim");
267
- }
268
- if (message.includes("quota") || message.includes("403")) {
269
- return new ProviderError("NVIDIA NIM quota exceeded for your account", "nvidia-nim");
270
- }
271
- return new ProviderError(`NVIDIA NIM error: ${message}`, "nvidia-nim");
240
+ const rules = [
241
+ // NIM canonically returns HTTP 401/Unauthorized for invalid API keys,
242
+ // but its OpenAI-compatible gateway sometimes surfaces a bare 400 +
243
+ // "Bad Request" with no body details for both malformed-credentials
244
+ // and bad-parameter cases. Because the two are indistinguishable from
245
+ // the message alone, bare 400/"Bad Request" is deliberately NOT
246
+ // promoted to "invalid key" here — that would mis-classify legitimate
247
+ // parameter errors (e.g. unsupported `reasoning_budget`, unsupported
248
+ // `chat_template`) as auth failures. Tests that probe the auth path
249
+ // (K1) detect "bad request" / "400" themselves; tests that probe
250
+ // parameter retry (K5) need the original "Bad Request" message to
251
+ // surface.
252
+ {
253
+ match: (ctx) => /Invalid API key|401|Unauthorized/.test(ctx.message),
254
+ errorClass: AuthenticationError,
255
+ message: "Invalid NVIDIA NIM API key. Get one at https://build.nvidia.com/settings/api-keys",
256
+ },
257
+ {
258
+ match: (ctx) => /rate limit|429/.test(ctx.message),
259
+ errorClass: RateLimitError,
260
+ message: "NVIDIA NIM rate limit exceeded",
261
+ },
262
+ {
263
+ match: (ctx) => /404|model_not_found/.test(ctx.message),
264
+ errorClass: InvalidModelError,
265
+ message: () => `NVIDIA NIM model '${this.modelName}' not available. Browse the catalog at https://build.nvidia.com/models`,
266
+ },
267
+ {
268
+ match: (ctx) => /quota|403/.test(ctx.message),
269
+ errorClass: ProviderError,
270
+ message: "NVIDIA NIM quota exceeded for your account",
271
+ },
272
+ ...DEFAULT_ERROR_RULES,
273
+ ];
274
+ return classifyProviderError(error, rules, "nvidia-nim", this.modelName);
272
275
  }
273
276
  async validateConfiguration() {
274
277
  return (typeof this.config.apiKey === "string" &&
@@ -1,6 +1,7 @@
1
1
  import { modelConfig } from "../../core/modelConfiguration.js";
2
2
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
3
3
  import { InvalidModelError, NetworkError, ProviderError, } from "../../types/index.js";
4
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
4
5
  import { logger } from "../../utils/logger.js";
5
6
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
6
7
  import { createTimeoutController, parseTimeout, TimeoutError, } from "../../utils/timeout.js";
@@ -81,47 +82,61 @@ export class OllamaProvider extends OpenAIChatCompletionsProvider {
81
82
  }
82
83
  formatProviderError(error) {
83
84
  if (error instanceof TimeoutError) {
85
+ // Custom message (not classifyProviderError's built-in "Request timed
86
+ // out: ..." default). TimeoutError is handled unconditionally inside
87
+ // classifyProviderError ahead of any rule table, so this quirk can only
88
+ // be preserved via a pre-delegate intercept (same pattern used for
89
+ // groq's TimeoutError override).
84
90
  return new NetworkError(`Ollama request timed out. The model may be loading or the request is too large.`, "ollama");
85
91
  }
92
+ // `responseBody` isn't part of ProviderErrorContext, so it's read off the
93
+ // raw error here (mirrors openAI's `errorType` extraction) for the
94
+ // missing-model / 404 rules below, which match against message+body
95
+ // combined exactly as the pre-migration code did.
86
96
  const errorRecord = error;
87
- const message = typeof errorRecord?.message === "string"
88
- ? errorRecord.message
89
- : "Unknown error";
90
- const cause = errorRecord?.cause ?? {};
91
- const code = (errorRecord?.code ?? cause?.code);
92
- if (code === "ECONNREFUSED" ||
93
- message.includes("ECONNREFUSED") ||
94
- message.includes("Failed to fetch") ||
95
- message.includes("fetch failed")) {
96
- return new NetworkError(`Cannot connect to Ollama at ${redactUrlCredentials(this.config.baseURL)}. ` +
97
- `Install Ollama (https://ollama.com), start it with 'ollama serve', then try again.`, "ollama");
98
- }
99
- // The base client (buildAPIError) attaches statusCode + responseBody to
100
- // HTTP failures. Distinguish a genuine missing-model error (give 'ollama
101
- // pull' guidance) from a bare endpoint-mismatch 404 (wrong base URL / not
102
- // the OpenAI-compatible /v1 surface) so the advice is actionable. Match on
103
- // wording, not a bare "404" substring, to avoid misclassifying unrelated
104
- // messages that merely contain those digits.
105
- const statusCode = typeof errorRecord?.statusCode === "number"
106
- ? errorRecord.statusCode
107
- : undefined;
108
97
  const responseBody = typeof errorRecord?.responseBody === "string"
109
98
  ? errorRecord.responseBody
110
99
  : "";
111
- const haystack = `${message} ${responseBody}`.toLowerCase();
112
- const looksLikeMissingModel = haystack.includes("model_not_found") ||
113
- (haystack.includes("model") && haystack.includes("not found"));
114
- if (looksLikeMissingModel) {
115
- return new InvalidModelError(`Ollama model '${this.modelName}' is not available locally. ` +
116
- `Pull it first with 'ollama pull ${this.modelName}' (or try '${FALLBACK_OLLAMA_MODEL}'); ` +
117
- `list installed models with 'ollama list'.`, "ollama");
118
- }
119
- if (statusCode === 404 || haystack.includes("status 404")) {
120
- return new ProviderError(`Ollama returned HTTP 404 from ${redactUrlCredentials(this.config.baseURL)}. ` +
121
- `Verify the base URL serves the OpenAI-compatible /v1 API and that the ` +
122
- `model is installed ('ollama list').`, "ollama");
123
- }
124
- return new ProviderError(`Ollama error: ${message}`, "ollama");
100
+ const cause = errorRecord?.cause ?? {};
101
+ const code = (errorRecord?.code ?? cause?.code);
102
+ const rules = [
103
+ {
104
+ match: (ctx) => code === "ECONNREFUSED" ||
105
+ /ECONNREFUSED|Failed to fetch|fetch failed/.test(ctx.message),
106
+ errorClass: NetworkError,
107
+ message: () => `Cannot connect to Ollama at ${redactUrlCredentials(this.config.baseURL)}. ` +
108
+ `Install Ollama (https://ollama.com), start it with 'ollama serve', then try again.`,
109
+ },
110
+ // The base client (buildAPIError) attaches statusCode + responseBody to
111
+ // HTTP failures. Distinguish a genuine missing-model error (give 'ollama
112
+ // pull' guidance) from a bare endpoint-mismatch 404 (wrong base URL / not
113
+ // the OpenAI-compatible /v1 surface) so the advice is actionable. Match
114
+ // on wording, not a bare "404" substring, to avoid misclassifying
115
+ // unrelated messages that merely contain those digits.
116
+ {
117
+ match: (ctx) => {
118
+ const haystack = `${ctx.message} ${responseBody}`.toLowerCase();
119
+ return (haystack.includes("model_not_found") ||
120
+ (haystack.includes("model") && haystack.includes("not found")));
121
+ },
122
+ errorClass: InvalidModelError,
123
+ message: () => `Ollama model '${this.modelName}' is not available locally. ` +
124
+ `Pull it first with 'ollama pull ${this.modelName}' (or try '${FALLBACK_OLLAMA_MODEL}'); ` +
125
+ `list installed models with 'ollama list'.`,
126
+ },
127
+ {
128
+ match: (ctx) => {
129
+ const haystack = `${ctx.message} ${responseBody}`.toLowerCase();
130
+ return ctx.statusCode === 404 || haystack.includes("status 404");
131
+ },
132
+ errorClass: ProviderError,
133
+ message: () => `Ollama returned HTTP 404 from ${redactUrlCredentials(this.config.baseURL)}. ` +
134
+ `Verify the base URL serves the OpenAI-compatible /v1 API and that the ` +
135
+ `model is installed ('ollama list').`,
136
+ },
137
+ ...DEFAULT_ERROR_RULES,
138
+ ];
139
+ return classifyProviderError(error, rules, "ollama", this.modelName);
125
140
  }
126
141
  // ===========================================================================
127
142
  // Optional hooks — Ollama-specific behaviour
@@ -1,14 +1,15 @@
1
1
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
2
  import { AIProviderName as AIProviderNameEnum } from "../../constants/enums.js";
3
3
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
4
- import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
4
+ import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../../types/index.js";
5
5
  import { logger } from "../../utils/logger.js";
6
6
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
7
7
  import { calculateCost } from "../../utils/pricing.js";
8
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
8
9
  import { createOpenAIConfig, getProviderModel, validateApiKey, } from "../../utils/providerConfig.js";
9
10
  import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../../utils/sizeGuard.js";
10
11
  import { assertSafeUrl } from "../../utils/ssrfGuard.js";
11
- import { createTimeoutController, TimeoutError } from "../../utils/timeout.js";
12
+ import { createTimeoutController } from "../../utils/timeout.js";
12
13
  import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
13
14
  import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
14
15
  const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
@@ -89,48 +90,44 @@ export class OpenAIProvider extends OpenAIChatCompletionsProvider {
89
90
  return getOpenAIModel();
90
91
  }
91
92
  formatProviderError(error) {
92
- if (error instanceof TimeoutError) {
93
- return new NetworkError(error.message, this.providerName);
94
- }
93
+ // `type` isn't part of `ProviderErrorContext` (it's an OpenAI-specific
94
+ // error-body field), so it's read directly off the raw error here and
95
+ // captured by the rule closures below.
95
96
  const errorObj = error;
96
- const message = errorObj?.message && typeof errorObj.message === "string"
97
- ? errorObj.message
98
- : "Unknown error";
99
97
  const errorType = errorObj?.type && typeof errorObj.type === "string"
100
98
  ? errorObj.type
101
99
  : undefined;
102
- const statusCode = typeof errorObj?.status === "number"
103
- ? errorObj.status
104
- : typeof errorObj?.statusCode === "number"
105
- ? errorObj.statusCode
106
- : undefined;
107
- // Curator P1-1 / Reviewer Finding #4: only the explicit auth markers
108
- // map to AuthenticationError. Earlier we treated every
109
- // `invalid_request_error` as an auth failure — that's OpenAI's catch-all
110
- // for any bad request (unsupported parameter, malformed JSON, etc.) and
111
- // mislabelled them as "invalid API key". Use credential-specific
112
- // signals only.
113
- if (message.includes("API_KEY_INVALID") ||
114
- message.includes("Invalid API key") ||
115
- message.includes("Incorrect API key") ||
116
- message.includes("invalid_api_key") ||
117
- errorType === "invalid_api_key" ||
118
- statusCode === 401) {
119
- return new AuthenticationError(message.includes("Incorrect API key") ||
120
- message.includes("Invalid API key")
121
- ? message
122
- : "Invalid OpenAI API key. Please check your OPENAI_API_KEY environment variable.", this.providerName);
123
- }
124
- if (message.includes("rate limit") ||
125
- errorType === "rate_limit_error" ||
126
- statusCode === 429) {
127
- return new RateLimitError("OpenAI rate limit exceeded. Please try again later.", this.providerName);
128
- }
129
- if (message.includes("model_not_found")) {
130
- return new InvalidModelError(`Model not found: ${this.modelName}`, this.providerName);
131
- }
132
- // Generic provider error
133
- return new ProviderError(`OpenAI error: ${message}`, this.providerName);
100
+ const rules = [
101
+ // Curator P1-1 / Reviewer Finding #4: only the explicit auth markers
102
+ // map to AuthenticationError. Earlier we treated every
103
+ // `invalid_request_error` as an auth failure — that's OpenAI's
104
+ // catch-all for any bad request (unsupported parameter, malformed
105
+ // JSON, etc.) and mislabelled them as "invalid API key". Use
106
+ // credential-specific signals only.
107
+ {
108
+ match: (ctx) => ctx.statusCode === 401 ||
109
+ errorType === "invalid_api_key" ||
110
+ /API_KEY_INVALID|Invalid API key|Incorrect API key|invalid_api_key/i.test(ctx.message),
111
+ errorClass: AuthenticationError,
112
+ message: (ctx) => /Incorrect API key|Invalid API key/i.test(ctx.message)
113
+ ? ctx.message
114
+ : "Invalid OpenAI API key. Please check your OPENAI_API_KEY environment variable.",
115
+ },
116
+ {
117
+ match: (ctx) => ctx.statusCode === 429 ||
118
+ errorType === "rate_limit_error" ||
119
+ /rate limit/i.test(ctx.message),
120
+ errorClass: RateLimitError,
121
+ message: "OpenAI rate limit exceeded. Please try again later.",
122
+ },
123
+ {
124
+ match: (ctx) => /model_not_found/i.test(ctx.message),
125
+ errorClass: InvalidModelError,
126
+ message: (ctx) => `Model not found: ${ctx.modelName}`,
127
+ },
128
+ ...DEFAULT_ERROR_RULES,
129
+ ];
130
+ return classifyProviderError(error, rules, this.providerName, this.modelName);
134
131
  }
135
132
  // ===========================================================================
136
133
  // Optional hook overrides