@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
@@ -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
@@ -1,10 +1,10 @@
1
1
  import { AIProviderName } from "../../constants/enums.js";
2
2
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../../types/index.js";
3
3
  import { createProxyFetch } from "../../proxy/proxyFetch.js";
4
+ import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../../utils/errorClassifier.js";
4
5
  import { isAbortError } from "../../utils/errorHandling.js";
5
6
  import { logger } from "../../utils/logger.js";
6
7
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
7
- import { TimeoutError } from "../../utils/timeout.js";
8
8
  import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
9
9
  import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
10
10
  import { getDefaultOpenRouterModel } from "./utils.js";
@@ -87,63 +87,69 @@ export class OpenRouterProvider extends OpenAIChatCompletionsProvider {
87
87
  return getDefaultOpenRouterModel();
88
88
  }
89
89
  formatProviderError(error) {
90
- if (error instanceof TimeoutError) {
91
- return new NetworkError(`Request timed out: ${error.message}`, "openrouter");
92
- }
93
- // Check for timeout by error name and message as fallback
94
- const errorRecord = error;
95
- if (errorRecord?.name === "TimeoutError" ||
96
- (typeof errorRecord?.message === "string" &&
97
- errorRecord.message.includes("Timeout"))) {
98
- return new NetworkError(`Request timed out: ${errorRecord?.message || "Unknown timeout"}`, "openrouter");
99
- }
100
- if (typeof errorRecord?.message === "string") {
101
- if (errorRecord.message.includes("ECONNREFUSED") ||
102
- errorRecord.message.includes("Failed to fetch")) {
103
- return new NetworkError("OpenRouter API not available. Please check your network connection and try again.", "openrouter");
104
- }
105
- if (errorRecord.message.includes("API_KEY_INVALID") ||
106
- errorRecord.message.includes("Invalid API key") ||
107
- errorRecord.message.includes("invalid_api_key") ||
108
- errorRecord.message.includes("Unauthorized")) {
109
- return new AuthenticationError("Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY environment variable. " +
110
- "Get your key at https://openrouter.ai/keys", "openrouter");
111
- }
112
- if (errorRecord.message.includes("rate limit")) {
113
- return new RateLimitError("OpenRouter rate limit exceeded. Please try again later or upgrade your account at https://openrouter.ai/credits", "openrouter");
114
- }
115
- if (errorRecord.message.includes("model") &&
116
- errorRecord.message.includes("not found")) {
117
- return new InvalidModelError(`Model '${this.modelName}' not available on OpenRouter. ` +
118
- "Browse available models at https://openrouter.ai/models", "openrouter");
119
- }
120
- if (errorRecord.message.includes("insufficient_credits")) {
121
- return new ProviderError("Insufficient OpenRouter credits. Add credits at https://openrouter.ai/credits", "openrouter");
122
- }
90
+ const rules = [
91
+ // Duck-typed timeout detection (name === "TimeoutError" OR message
92
+ // contains "Timeout", case-sensitive — matches the original's
93
+ // `.includes("Timeout")`) distinct from the `instanceof TimeoutError`
94
+ // check classifyProviderError already performs first.
95
+ {
96
+ match: (ctx) => ctx.errorName === "TimeoutError" || /Timeout/.test(ctx.message),
97
+ errorClass: NetworkError,
98
+ message: (ctx) => `Request timed out: ${ctx.message}`,
99
+ },
100
+ {
101
+ match: (ctx) => /ECONNREFUSED|Failed to fetch/.test(ctx.message),
102
+ errorClass: NetworkError,
103
+ message: "OpenRouter API not available. Please check your network connection and try again.",
104
+ },
105
+ {
106
+ match: (ctx) => /API_KEY_INVALID|Invalid API key|invalid_api_key|Unauthorized/.test(ctx.message),
107
+ errorClass: AuthenticationError,
108
+ message: "Invalid OpenRouter API key. Please check your OPENROUTER_API_KEY environment variable. " +
109
+ "Get your key at https://openrouter.ai/keys",
110
+ },
111
+ {
112
+ match: (ctx) => /rate limit/.test(ctx.message),
113
+ errorClass: RateLimitError,
114
+ message: "OpenRouter rate limit exceeded. Please try again later or upgrade your account at https://openrouter.ai/credits",
115
+ },
116
+ {
117
+ match: (ctx) => /model/.test(ctx.message) && /not found/.test(ctx.message),
118
+ errorClass: InvalidModelError,
119
+ message: () => `Model '${this.modelName}' not available on OpenRouter. ` +
120
+ "Browse available models at https://openrouter.ai/models",
121
+ },
122
+ {
123
+ match: (ctx) => /insufficient_credits/.test(ctx.message),
124
+ errorClass: ProviderError,
125
+ message: "Insufficient OpenRouter credits. Add credits at https://openrouter.ai/credits",
126
+ },
123
127
  // "No endpoints found" — model temporarily unavailable or unsupported
124
128
  // parameters. Distinct from tool errors: it can happen on any request
125
129
  // when the model has no available providers on OpenRouter.
126
- if (errorRecord.message.includes("No endpoints found")) {
127
- return new InvalidModelError(`No endpoints found for model '${this.modelName}' on OpenRouter. ` +
130
+ {
131
+ match: (ctx) => /No endpoints found/.test(ctx.message),
132
+ errorClass: InvalidModelError,
133
+ message: () => `No endpoints found for model '${this.modelName}' on OpenRouter. ` +
128
134
  "The model may be temporarily unavailable or does not support the requested parameters. " +
129
- "Try a different model or check availability at https://openrouter.ai/models", "openrouter");
130
- }
135
+ "Try a different model or check availability at https://openrouter.ai/models",
136
+ },
131
137
  // Tool/function calling errors
132
- if (errorRecord.message.includes("tool use") ||
133
- errorRecord.message.includes("tool_use") ||
134
- errorRecord.message.includes("function_call") ||
135
- errorRecord.message.includes("tools are not supported")) {
136
- return new ProviderError(`Model '${this.modelName}' does not support tool calling. ` +
138
+ {
139
+ match: (ctx) => /tool use|tool_use|function_call|tools are not supported/.test(ctx.message),
140
+ errorClass: ProviderError,
141
+ message: () => `Model '${this.modelName}' does not support tool calling. ` +
137
142
  "Use a tool-capable model like:\n" +
138
143
  " • google/gemini-2.0-flash-exp:free (free)\n" +
139
144
  " • meta-llama/llama-3.3-70b-instruct:free (free)\n" +
140
145
  " • anthropic/claude-3.7-sonnet (paid)\n" +
141
146
  " • openai/gpt-4o (paid)\n" +
142
147
  "Or use --disableTools flag. " +
143
- "See all tool-capable models at https://openrouter.ai/models?supported_parameters=tools", "openrouter");
144
- }
145
- }
146
- return new ProviderError(`OpenRouter error: ${errorRecord?.message || "Unknown error"}`, "openrouter");
148
+ "See all tool-capable models at https://openrouter.ai/models?supported_parameters=tools",
149
+ },
150
+ ...DEFAULT_ERROR_RULES,
151
+ ];
152
+ return classifyProviderError(error, rules, "openrouter", this.modelName);
147
153
  }
148
154
  // ===========================================================================
149
155
  // Optional hooks — provider-specific quirks
@@ -17,6 +17,7 @@
17
17
  * Nothing here imports from "ai" or "@ai-sdk/*". The base class is a
18
18
  * direct HTTP client + multi-step tool-execution loop driven by SSE.
19
19
  */
20
+ import { trace } from "@opentelemetry/api";
20
21
  import { getAvailableInputTokens, getRuntimeContextWindow, getRuntimeOutputCeiling, registerRuntimeContextWindow, } from "../constants/contextWindows.js";
21
22
  import { guardOpenAICompatConversation } from "../context/openaiCompatLoopGuard.js";
22
23
  import { isContextOverflowError, parseProviderOverflowDetails, } from "../context/errorDetection.js";
@@ -33,6 +34,7 @@ import { composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals,
33
34
  import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
34
35
  import { resolveToolChoice } from "../utils/toolChoice.js";
35
36
  import { transformToolExecutions } from "../utils/transformationUtils.js";
37
+ import { withProviderRetry } from "../utils/providerRetry.js";
36
38
  import { resolveDeferredTool } from "../tools/toolDiscovery.js";
37
39
  import { buildAPIError, buildBody, buildToolsForOpenAI, buildWireToolNameMaps, createChunkQueue, createDeferredAnalytics, ensureJsonWordInBody, estimateWireTokens, mapNeuroLinkToolChoice, mergeUsage, messageBuilderToOpenAI, parseSSEStream, stringifyToolOutput, stripTrailingSlash, v3ResponseFormatToOpenAI, v3ToolChoiceToOpenAI, v3ToolsToOpenAI, } from "./openaiChatCompletionsClient.js";
38
40
  /**
@@ -932,22 +934,44 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
932
934
  : {}),
933
935
  streaming: true,
934
936
  }), args.modelId));
935
- let res = await args.fetchImpl(args.url, {
936
- method: "POST",
937
- headers: {
938
- "Content-Type": "application/json",
939
- ...this.getAuthHeaders(),
940
- },
941
- body: JSON.stringify(body),
942
- ...(args.abortSignal ? { signal: args.abortSignal } : {}),
943
- });
944
- if (!res.ok) {
945
- const apiErr = await buildAPIError(args.url, body, res);
946
- // One-shot 400 retry — overflow corrector first (re-fits max_tokens
947
- // from the provider's own numbers + self-heals the window registry),
948
- // then the subclass hook (e.g. NIM strips chat_template /
949
- // reasoning_budget when a model rejects them).
950
- const retryBody = res.status === 400
937
+ // The initial fetch gets 429/5xx retry-with-backoff via the same
938
+ // primitive the non-streaming path already uses (withProviderRetry).
939
+ // `doFetch` throws the classified APIError (buildAPIError attaches
940
+ // .statusCode + .responseHeaders, which withProviderRetry's duck-typing
941
+ // reads directly) so a non-ok response is what drives the retry
942
+ // decision, not a return value.
943
+ const doFetch = async () => {
944
+ const attemptRes = await args.fetchImpl(args.url, {
945
+ method: "POST",
946
+ headers: {
947
+ "Content-Type": "application/json",
948
+ ...this.getAuthHeaders(),
949
+ },
950
+ body: JSON.stringify(body),
951
+ ...(args.abortSignal ? { signal: args.abortSignal } : {}),
952
+ });
953
+ if (!attemptRes.ok) {
954
+ throw await buildAPIError(args.url, body, attemptRes);
955
+ }
956
+ return attemptRes;
957
+ };
958
+ let res;
959
+ try {
960
+ res = await withProviderRetry(doFetch, trace.getActiveSpan() ?? undefined, `${this.providerName} stream`);
961
+ }
962
+ catch (err) {
963
+ // The one-shot 400 context-overflow fallback lives outside
964
+ // withProviderRetry (400 isn't retryable there anyway — see
965
+ // isRetryableProviderError), so it fires on the classified error's
966
+ // .statusCode. The raw Response is no longer in scope here: it was
967
+ // consumed inside doFetch's closure, either returned on success or
968
+ // discarded after buildAPIError read its body on failure.
969
+ const apiErr = err;
970
+ // Overflow corrector first (re-fits max_tokens from the provider's
971
+ // own numbers + self-heals the window registry), then the subclass
972
+ // hook (e.g. NIM strips chat_template / reasoning_budget when a model
973
+ // rejects them).
974
+ const retryBody = apiErr.statusCode === 400
951
975
  ? (this.correctBodyAfterContextOverflow(body, apiErr) ??
952
976
  this.adjustBodyAfter400(body, apiErr))
953
977
  : undefined;