@juspay/neurolink 12.23.2 → 12.24.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 (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/browser/neurolink.min.js +367 -367
  3. package/dist/constants/enums.d.ts +61 -69
  4. package/dist/constants/enums.js +63 -96
  5. package/dist/core/nativeGenerateLoop.js +11 -1
  6. package/dist/factories/providerDescriptors.d.ts +1 -1
  7. package/dist/factories/providerDescriptors.js +50 -83
  8. package/dist/factories/providerRegistry.js +9 -16
  9. package/dist/models/manifestRegistry.js +11 -2
  10. package/dist/providers/anthropic/client.js +7 -1
  11. package/dist/providers/catalog/deepseek.json +84 -0
  12. package/dist/providers/catalog/huggingface.json +405 -0
  13. package/dist/providers/catalog/index.generated.d.ts +1 -1
  14. package/dist/providers/catalog/index.generated.js +6 -0
  15. package/dist/providers/catalog/loader.js +20 -5
  16. package/dist/providers/catalog/mistral.json +8 -3
  17. package/dist/providers/catalog/provider-catalog.schema.json +31 -1
  18. package/dist/providers/catalog/schema.d.ts +8 -1
  19. package/dist/providers/catalog/schema.js +9 -1
  20. package/dist/providers/configuredOpenAICompat.d.ts +14 -5
  21. package/dist/providers/configuredOpenAICompat.js +19 -4
  22. package/dist/providers/embeddingResponseParsing.d.ts +1 -0
  23. package/dist/providers/embeddingResponseParsing.js +47 -0
  24. package/dist/providers/ideogram.js +7 -1
  25. package/dist/providers/jina.js +6 -15
  26. package/dist/providers/llamaCpp.js +4 -15
  27. package/dist/providers/lmStudio.js +4 -15
  28. package/dist/providers/localRuntimeOpenAICompat.d.ts +16 -0
  29. package/dist/providers/localRuntimeOpenAICompat.js +26 -0
  30. package/dist/providers/ollama/client.js +3 -9
  31. package/dist/providers/openaiChatCompletionsBase.d.ts +3 -3
  32. package/dist/providers/openaiChatCompletionsBase.js +20 -5
  33. package/dist/providers/openaiCompatCatalog.d.ts +16 -12
  34. package/dist/providers/openaiCompatCatalog.js +16 -12
  35. package/dist/providers/voyage.js +5 -18
  36. package/dist/types/providerCatalog.d.ts +46 -1
  37. package/dist/types/providerCatalog.generated.d.ts +2 -2
  38. package/dist/types/providers.d.ts +12 -8
  39. package/dist/utils/modelChoices.d.ts +2 -2
  40. package/dist/utils/modelChoices.js +10 -38
  41. package/dist/utils/sizeGuard.d.ts +8 -5
  42. package/dist/utils/sizeGuard.js +32 -9
  43. package/docs-site/static/search-index.json +1 -1
  44. package/package.json +2 -1
  45. package/dist/providers/deepseek.d.ts +0 -47
  46. package/dist/providers/deepseek.js +0 -120
  47. package/dist/providers/huggingFace/client.d.ts +0 -54
  48. package/dist/providers/huggingFace/client.js +0 -120
  49. package/dist/providers/huggingFace/index.d.ts +0 -1
  50. package/dist/providers/huggingFace/index.js +0 -1
@@ -41,11 +41,12 @@ export function catalogEnvVar(entry, kind) {
41
41
  ? `${base}_BASE_URL`
42
42
  : `${base}_MODEL`;
43
43
  }
44
- function interpolate(template, entry, modelName) {
44
+ function interpolate(template, entry, modelName, rawMessage) {
45
45
  return template
46
46
  .replace(/\{apiKeyEnvVar\}/g, catalogEnvVar(entry, "apiKey"))
47
47
  .replace(/\{setupUrl\}/g, entry.setup.url)
48
- .replace(/\{model\}/g, modelName ?? "");
48
+ .replace(/\{model\}/g, modelName ?? "")
49
+ .replace(/\{message\}/g, rawMessage ?? "");
49
50
  }
50
51
  export function buildCatalogConfigOptions(entry) {
51
52
  return {
@@ -54,17 +55,21 @@ export function buildCatalogConfigOptions(entry) {
54
55
  setupUrl: entry.setup.url,
55
56
  description: entry.setup.description ?? "API key",
56
57
  instructions: entry.setup.instructions.map((line) => interpolate(line, entry)),
58
+ ...(entry.wire.apiKeyFallbackEnvVars
59
+ ? { fallbackEnvVars: entry.wire.apiKeyFallbackEnvVars }
60
+ : {}),
57
61
  };
58
62
  }
59
63
  function buildErrorRules(entry) {
60
64
  const bespoke = entry.errorRules.map((rule) => {
61
65
  const regex = rule.pattern ? new RegExp(rule.pattern, "i") : undefined;
66
+ const needsContext = rule.message.includes("{model}") || rule.message.includes("{message}");
62
67
  return {
63
68
  match: (ctx) => (rule.status !== undefined && ctx.statusCode === rule.status) ||
64
69
  (regex !== undefined && regex.test(ctx.message)),
65
70
  errorClass: ERROR_CLASS_MAP[rule.class],
66
- message: rule.message.includes("{model}")
67
- ? (ctx) => interpolate(rule.message, entry, ctx.modelName)
71
+ message: needsContext
72
+ ? (ctx) => interpolate(rule.message, entry, ctx.modelName, ctx.message)
68
73
  : interpolate(rule.message, entry),
69
74
  };
70
75
  });
@@ -118,7 +123,14 @@ export function buildCatalogEntries() {
118
123
  entry.models.fallbacks[0],
119
124
  fallbackModels: [...entry.models.fallbacks],
120
125
  errorRules: buildErrorRules(entry),
121
- supportsTools: entry.capabilities.tools,
126
+ // "model-dependent" leaves this field unset entirely (not the string
127
+ // itself, which OpenAICompatCatalogEntry.supportsTools types as
128
+ // boolean|undefined) so ConfiguredOpenAICompatProvider.supportsTools()
129
+ // falls through to the model-registry default, same as an entry that
130
+ // never set this field at all.
131
+ ...(typeof entry.capabilities.tools === "boolean"
132
+ ? { supportsTools: entry.capabilities.tools }
133
+ : {}),
122
134
  };
123
135
  const { baseURLTemplate } = entry.wire;
124
136
  if (baseURLTemplate) {
@@ -143,6 +155,9 @@ export function buildCatalogEntries() {
143
155
  if (entry.quirks?.timeoutErrorClass === "provider") {
144
156
  base.timeoutErrorClass = ProviderError;
145
157
  }
158
+ if (entry.quirks?.responseFormatDowngrade) {
159
+ base.responseFormatDowngrade = entry.quirks.responseFormatDowngrade;
160
+ }
146
161
  return base;
147
162
  });
148
163
  }
@@ -223,12 +223,17 @@
223
223
  "quirks": {
224
224
  "registryDefaultIgnoresModelEnvVar": true
225
225
  },
226
+ "timeouts": {
227
+ "generateMs": 45000,
228
+ "streamMs": 120000
229
+ },
230
+ "autoSelectPriority": 9,
226
231
  "setup": {
227
- "url": "https://console.mistral.ai/",
228
- "apiKeyFormat": null,
232
+ "url": "https://console.mistral.ai/api-keys",
233
+ "apiKeyFormat": "^[A-Za-z0-9]{32}$",
229
234
  "billingPolicy": "free-with-card",
230
235
  "instructions": [
231
- "1. Visit: https://console.mistral.ai/",
236
+ "1. Visit: https://console.mistral.ai/api-keys",
232
237
  "2. Create or sign in to your account",
233
238
  "3. Generate a new API key"
234
239
  ]
@@ -67,6 +67,13 @@
67
67
  "missingCredentialMessage": {
68
68
  "type": "string"
69
69
  },
70
+ "apiKeyFallbackEnvVars": {
71
+ "type": "array",
72
+ "items": {
73
+ "type": "string"
74
+ },
75
+ "description": "Additional env vars validateApiKey() tries, in order, when the primary apiKeyEnvVar is unset (e.g. HF_TOKEN alongside HUGGINGFACE_API_KEY)."
76
+ },
70
77
  "envOverrides": {
71
78
  "type": "object",
72
79
  "additionalProperties": false,
@@ -205,7 +212,8 @@
205
212
  "type": "boolean"
206
213
  },
207
214
  "tools": {
208
- "type": "boolean"
215
+ "anyOf": [{ "type": "boolean" }, { "const": "model-dependent" }],
216
+ "description": "\"model-dependent\" when tool support varies per served model and the vendor doesn't reject tools for unsupported ones (e.g. HuggingFace's router)."
209
217
  },
210
218
  "toolsWithStreaming": {
211
219
  "type": "boolean"
@@ -267,9 +275,31 @@
267
275
  "type": "string",
268
276
  "enum": ["string"],
269
277
  "description": "Vendor accepts messages[].content only as a plain string, rejecting OpenAI's content-parts array and the null used on an assistant message with tool_calls. Normalized by ConfiguredOpenAICompatProvider."
278
+ },
279
+ "responseFormatDowngrade": {
280
+ "type": "string",
281
+ "enum": ["json-schema-to-json-object"],
282
+ "description": "Vendor rejects response_format: { type: \"json_schema\" } outright but accepts { type: \"json_object\" }. ConfiguredOpenAICompatProvider downgrades the request so generate({ schema }) keeps working."
283
+ }
284
+ }
285
+ },
286
+ "timeouts": {
287
+ "type": "object",
288
+ "additionalProperties": false,
289
+ "description": "Descriptor-only generate/stream turn-budget override consumed by buildCatalogDescriptor(); never read by the OpenAI-compat runtime path.",
290
+ "properties": {
291
+ "generateMs": {
292
+ "type": "number"
293
+ },
294
+ "streamMs": {
295
+ "type": "number"
270
296
  }
271
297
  }
272
298
  },
299
+ "autoSelectPriority": {
300
+ "type": "number",
301
+ "description": "Descriptor-only ascending auto-select priority override (lower = tried first in getBestProvider()'s fallback chain). Absent = provider is not part of the auto-select chain."
302
+ },
273
303
  "setup": {
274
304
  "type": "object",
275
305
  "additionalProperties": false,
@@ -23,6 +23,7 @@ export declare const providerCatalogJsonSchema: z.ZodObject<{
23
23
  baseURLTemplate: z.ZodOptional<z.ZodString>;
24
24
  extraCredentials: z.ZodOptional<z.ZodArray<z.ZodString>>;
25
25
  missingCredentialMessage: z.ZodOptional<z.ZodString>;
26
+ apiKeyFallbackEnvVars: z.ZodOptional<z.ZodArray<z.ZodString>>;
26
27
  envOverrides: z.ZodOptional<z.ZodObject<{
27
28
  apiKey: z.ZodOptional<z.ZodString>;
28
29
  baseURL: z.ZodOptional<z.ZodString>;
@@ -60,7 +61,7 @@ export declare const providerCatalogJsonSchema: z.ZodObject<{
60
61
  capabilities: z.ZodObject<{
61
62
  text: z.ZodBoolean;
62
63
  streaming: z.ZodBoolean;
63
- tools: z.ZodBoolean;
64
+ tools: z.ZodUnion<readonly [z.ZodBoolean, z.ZodLiteral<"model-dependent">]>;
64
65
  toolsWithStreaming: z.ZodBoolean;
65
66
  structuredOutput: z.ZodBoolean;
66
67
  structuredOutputWithTools: z.ZodBoolean;
@@ -83,7 +84,13 @@ export declare const providerCatalogJsonSchema: z.ZodObject<{
83
84
  timeoutErrorClass: z.ZodOptional<z.ZodLiteral<"provider">>;
84
85
  messageContentFormat: z.ZodOptional<z.ZodLiteral<"string">>;
85
86
  registryDefaultIgnoresModelEnvVar: z.ZodOptional<z.ZodBoolean>;
87
+ responseFormatDowngrade: z.ZodOptional<z.ZodLiteral<"json-schema-to-json-object">>;
86
88
  }, z.core.$strict>>;
89
+ timeouts: z.ZodOptional<z.ZodObject<{
90
+ generateMs: z.ZodOptional<z.ZodNumber>;
91
+ streamMs: z.ZodOptional<z.ZodNumber>;
92
+ }, z.core.$strict>>;
93
+ autoSelectPriority: z.ZodOptional<z.ZodNumber>;
87
94
  setup: z.ZodObject<{
88
95
  url: z.ZodString;
89
96
  apiKeyFormat: z.ZodNullable<z.ZodString>;
@@ -36,6 +36,7 @@ const catalogWireSchema = z
36
36
  baseURLTemplate: z.string().optional(),
37
37
  extraCredentials: z.array(z.string()).optional(),
38
38
  missingCredentialMessage: z.string().optional(),
39
+ apiKeyFallbackEnvVars: z.array(z.string()).optional(),
39
40
  envOverrides: z
40
41
  .strictObject({
41
42
  apiKey: z.string().optional(),
@@ -138,6 +139,11 @@ const catalogQuirksSchema = z.strictObject({
138
139
  timeoutErrorClass: z.literal("provider").optional(),
139
140
  messageContentFormat: z.literal("string").optional(),
140
141
  registryDefaultIgnoresModelEnvVar: z.boolean().optional(),
142
+ responseFormatDowngrade: z.literal("json-schema-to-json-object").optional(),
143
+ });
144
+ const catalogTimeoutsSchema = z.strictObject({
145
+ generateMs: z.number().optional(),
146
+ streamMs: z.number().optional(),
141
147
  });
142
148
  const catalogBillingPolicySchema = z.enum([
143
149
  "free-tier",
@@ -172,7 +178,7 @@ const catalogEvidenceSchema = z.strictObject({
172
178
  const catalogCapabilitiesSchema = z.strictObject({
173
179
  text: z.boolean(),
174
180
  streaming: z.boolean(),
175
- tools: z.boolean(),
181
+ tools: z.union([z.boolean(), z.literal("model-dependent")]),
176
182
  toolsWithStreaming: z.boolean(),
177
183
  structuredOutput: z.boolean(),
178
184
  structuredOutputWithTools: z.boolean(),
@@ -331,6 +337,8 @@ export const providerCatalogJsonSchema = z.strictObject({
331
337
  capabilities: catalogCapabilitiesSchema,
332
338
  errorRules: z.array(catalogErrorRuleJsonSchema),
333
339
  quirks: catalogQuirksSchema.optional(),
340
+ timeouts: catalogTimeoutsSchema.optional(),
341
+ autoSelectPriority: z.number().optional(),
334
342
  setup: catalogSetupSchema,
335
343
  evidence: catalogEvidenceSchema,
336
344
  });
@@ -1,5 +1,5 @@
1
1
  import type { AIProviderName } from "../constants/enums.js";
2
- import type { OpenAICompatCatalogEntry, OpenAICompatChatRequest, OpenAICompatCredentials } from "../types/index.js";
2
+ import type { OpenAICompatCatalogEntry, OpenAICompatChatRequest, OpenAICompatCredentials, OpenAICompatResponseFormat } from "../types/index.js";
3
3
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
4
4
  /**
5
5
  * Generic OpenAI-compatible provider driven entirely by an
@@ -11,13 +11,13 @@ import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
11
11
  * If a provider needs a real hook override (adjustRequestBody,
12
12
  * adjustBodyAfter400, getChatCompletionsURL, getAuthHeaders,
13
13
  * suppressResponseFormatWithTools, ...) it does NOT belong in the catalog —
14
- * write a dedicated subclass instead (see deepseek.ts, azureOpenai.ts).
14
+ * write a dedicated subclass instead (see azureOpenai.ts).
15
15
  *
16
16
  * The exception is a WIRE DIALECT: a vendor that speaks OpenAI for ordinary
17
17
  * chat but encodes one part of the request differently. Expressing that as
18
- * data (the catalog's `messageContentFormat`) keeps the provider a
19
- * one-JSON-file entry instead of promoting it to a hand-written subclass
20
- * over a single incompatibility.
18
+ * data (the catalog's `messageContentFormat` / `responseFormatDowngrade`)
19
+ * keeps the provider a one-JSON-file entry instead of promoting it to a
20
+ * hand-written subclass over a single incompatibility.
21
21
  *
22
22
  * Today that is Cloudflare Workers AI, whose OpenAI-compatible endpoint
23
23
  * accepts `messages[].content` only as a plain string — never the
@@ -28,6 +28,14 @@ import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
28
28
  * every tool round-trip fails. Normalizing content to a string below is the
29
29
  * whole fix: tools, tool_choice, the `tool` role and `tool_calls` are all
30
30
  * accepted as-is.
31
+ *
32
+ * The same shape covers DeepSeek: its `/chat/completions` rejects
33
+ * `response_format: { type: "json_schema" }` outright ("This response_format
34
+ * type is unavailable now"), but accepts `{ type: "json_object" }`. The
35
+ * catalog's `responseFormatDowngrade` quirk downgrades the request so
36
+ * `generate({ schema })` keeps working, matching the
37
+ * `supportsStructuredOutputs: false` behavior of the `@ai-sdk/openai-compatible`
38
+ * provider this replaced.
31
39
  */
32
40
  export declare class ConfiguredOpenAICompatProvider extends OpenAIChatCompletionsProvider {
33
41
  private readonly entry;
@@ -48,5 +56,6 @@ export declare class ConfiguredOpenAICompatProvider extends OpenAIChatCompletion
48
56
  */
49
57
  supportsTools(): boolean;
50
58
  protected adjustRequestBody(body: OpenAICompatChatRequest, modelId: string): OpenAICompatChatRequest;
59
+ protected adjustResponseFormat(rf: OpenAICompatResponseFormat | undefined, modelId: string): OpenAICompatResponseFormat | undefined;
51
60
  protected formatProviderError(error: unknown): Error;
52
61
  }
@@ -33,13 +33,13 @@ const flattenMessageContent = (content) => {
33
33
  * If a provider needs a real hook override (adjustRequestBody,
34
34
  * adjustBodyAfter400, getChatCompletionsURL, getAuthHeaders,
35
35
  * suppressResponseFormatWithTools, ...) it does NOT belong in the catalog —
36
- * write a dedicated subclass instead (see deepseek.ts, azureOpenai.ts).
36
+ * write a dedicated subclass instead (see azureOpenai.ts).
37
37
  *
38
38
  * The exception is a WIRE DIALECT: a vendor that speaks OpenAI for ordinary
39
39
  * chat but encodes one part of the request differently. Expressing that as
40
- * data (the catalog's `messageContentFormat`) keeps the provider a
41
- * one-JSON-file entry instead of promoting it to a hand-written subclass
42
- * over a single incompatibility.
40
+ * data (the catalog's `messageContentFormat` / `responseFormatDowngrade`)
41
+ * keeps the provider a one-JSON-file entry instead of promoting it to a
42
+ * hand-written subclass over a single incompatibility.
43
43
  *
44
44
  * Today that is Cloudflare Workers AI, whose OpenAI-compatible endpoint
45
45
  * accepts `messages[].content` only as a plain string — never the
@@ -50,6 +50,14 @@ const flattenMessageContent = (content) => {
50
50
  * every tool round-trip fails. Normalizing content to a string below is the
51
51
  * whole fix: tools, tool_choice, the `tool` role and `tool_calls` are all
52
52
  * accepted as-is.
53
+ *
54
+ * The same shape covers DeepSeek: its `/chat/completions` rejects
55
+ * `response_format: { type: "json_schema" }` outright ("This response_format
56
+ * type is unavailable now"), but accepts `{ type: "json_object" }`. The
57
+ * catalog's `responseFormatDowngrade` quirk downgrades the request so
58
+ * `generate({ schema })` keeps working, matching the
59
+ * `supportsStructuredOutputs: false` behavior of the `@ai-sdk/openai-compatible`
60
+ * provider this replaced.
53
61
  */
54
62
  export class ConfiguredOpenAICompatProvider extends OpenAIChatCompletionsProvider {
55
63
  entry;
@@ -115,6 +123,13 @@ export class ConfiguredOpenAICompatProvider extends OpenAIChatCompletionsProvide
115
123
  })),
116
124
  };
117
125
  }
126
+ adjustResponseFormat(rf, modelId) {
127
+ if (this.entry.responseFormatDowngrade === "json-schema-to-json-object" &&
128
+ rf?.type === "json_schema") {
129
+ return { type: "json_object" };
130
+ }
131
+ return super.adjustResponseFormat(rf, modelId);
132
+ }
118
133
  formatProviderError(error) {
119
134
  // classifyProviderError hard-codes TimeoutError -> NetworkError ahead
120
135
  // of any rule table and does not allow a per-provider override. An
@@ -0,0 +1 @@
1
+ export declare function parseIndexedEmbeddingsResponse(data: unknown, inputCount: number, providerLabel: string, makeError: (message: string) => Error): number[][];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared embeddings-response validation for Voyage and Jina — both call
3
+ * POST /embeddings with an identical `{input, model}` body and get back an
4
+ * identical `{data: [{embedding, index}]}` shape, and both had byte-identical
5
+ * validation logic (same three message templates, same sort-by-index +
6
+ * 0..n-1 coverage check) before this was factored out.
7
+ *
8
+ * A plain function, not a shared base class: Voyage throws typed
9
+ * `ProviderError` here, Jina throws plain `Error` (a real, pre-existing
10
+ * inconsistency between the two — not something this refactor silently
11
+ * fixes). `makeError` preserves each provider's exact existing behavior;
12
+ * `providerLabel` preserves each provider's exact existing message text
13
+ * ("Voyage ..." / "Jina ...").
14
+ */
15
+ const isIndexedEmbedding = (value) => {
16
+ if (typeof value !== "object" || value === null) {
17
+ return false;
18
+ }
19
+ const { embedding, index } = value;
20
+ return (Number.isInteger(index) &&
21
+ Array.isArray(embedding) &&
22
+ embedding.every((n) => typeof n === "number" && Number.isFinite(n)));
23
+ };
24
+ export function parseIndexedEmbeddingsResponse(data, inputCount, providerLabel, makeError) {
25
+ const entries = typeof data === "object" && data !== null
26
+ ? data.data
27
+ : undefined;
28
+ if (!Array.isArray(entries) || entries.length === 0) {
29
+ throw makeError(`${providerLabel} embeddings response missing data`);
30
+ }
31
+ if (entries.length !== inputCount) {
32
+ throw makeError(`${providerLabel} embeddings response count mismatch: expected ${inputCount}, got ${entries.length}`);
33
+ }
34
+ const malformed = entries.findIndex((entry) => !isIndexedEmbedding(entry));
35
+ if (malformed !== -1) {
36
+ throw makeError(`${providerLabel} embeddings response entry ${malformed} is not an {index, embedding: number[]} object`);
37
+ }
38
+ const sorted = entries
39
+ .slice()
40
+ .sort((a, b) => a.index - b.index);
41
+ for (let i = 0; i < sorted.length; i++) {
42
+ if (sorted[i].index !== i) {
43
+ throw makeError(`${providerLabel} embeddings response has unexpected index ordering: position ${i} has index ${sorted[i].index}`);
44
+ }
45
+ }
46
+ return sorted.map((d) => d.embedding);
47
+ }
@@ -5,6 +5,8 @@ import { createProxyFetch } from "../proxy/proxyFetch.js";
5
5
  import { AuthenticationError, ProviderError, RateLimitError, } from "../types/index.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  import { createIdeogramConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
8
+ import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../utils/sizeGuard.js";
9
+ import { assertSafeUrl } from "../utils/ssrfGuard.js";
8
10
  const IDEOGRAM_DEFAULT_BASE_URL = "https://api.ideogram.ai";
9
11
  const REQUEST_TIMEOUT_MS = 120_000;
10
12
  const getIdeogramApiKey = () => validateApiKey(createIdeogramConfig());
@@ -132,6 +134,10 @@ export class IdeogramProvider extends BaseProvider {
132
134
  if (!url) {
133
135
  throw new Error("Ideogram returned no image URL");
134
136
  }
137
+ // Guard the API-returned URL before fetching (provider-returned URLs
138
+ // carry the same SSRF risk as caller-supplied ones) — matches Recraft's
139
+ // equivalent download path.
140
+ await assertSafeUrl(url);
135
141
  // Download the image and convert to base64 to match the imageOutput
136
142
  // contract used by other image-gen providers. Apply a 60s timeout so the
137
143
  // download cannot hang indefinitely.
@@ -155,7 +161,7 @@ export class IdeogramProvider extends BaseProvider {
155
161
  if (!dl.ok) {
156
162
  throw new Error(`Failed to download Ideogram image: ${dl.status}`);
157
163
  }
158
- const buffer = Buffer.from(await dl.arrayBuffer());
164
+ const buffer = await readBoundedBuffer(dl, MAX_IMAGE_BYTES, "Ideogram image");
159
165
  const base64 = buffer.toString("base64");
160
166
  const generationTimeMs = Date.now() - startTime;
161
167
  logger.info(`[IdeogramProvider] Generated image (${buffer.length} bytes) in ${generationTimeMs}ms — model ${this.modelName}`);
@@ -1,6 +1,7 @@
1
1
  import { JinaModels } from "../constants/enums.js";
2
2
  import { BaseProvider } from "../core/baseProvider.js";
3
3
  import { isNeuroLink } from "../neurolink.js";
4
+ import { parseIndexedEmbeddingsResponse } from "./embeddingResponseParsing.js";
4
5
  import { createProxyFetch } from "../proxy/proxyFetch.js";
5
6
  import { AuthenticationError, InvalidModelError, ProviderError, RateLimitError, } from "../types/index.js";
6
7
  import { logger } from "../utils/logger.js";
@@ -190,21 +191,11 @@ export class JinaProvider extends BaseProvider {
190
191
  throw this.formatProviderError(new Error(`Jina embeddings failed: ${response.status} — ${text}`));
191
192
  }
192
193
  const data = (await response.json());
193
- if (!data.data || data.data.length === 0) {
194
- throw new Error("Jina embeddings response missing data");
195
- }
196
- // Validate that the response covers all requested inputs.
197
- if (data.data.length !== inputs.length) {
198
- throw new Error(`Jina embeddings response count mismatch: expected ${inputs.length}, got ${data.data.length}`);
199
- }
200
- // Sort by index and verify sequential coverage (0 … n-1).
201
- const sorted = data.data.slice().sort((a, b) => a.index - b.index);
202
- for (let i = 0; i < sorted.length; i++) {
203
- if (sorted[i].index !== i) {
204
- throw new Error(`Jina embeddings response has unexpected index ordering: position ${i} has index ${sorted[i].index}`);
205
- }
206
- }
207
- return sorted.map((d) => d.embedding);
194
+ // Sorts by index and verifies sequential 0..n-1 coverage. Note: throws
195
+ // plain Error, not ProviderError, unlike Voyage's equivalent — a real,
196
+ // pre-existing inconsistency between the two, preserved as-is rather
197
+ // than silently "fixed" by this refactor.
198
+ return parseIndexedEmbeddingsResponse(data, inputs.length, "Jina", (message) => new Error(message));
208
199
  }
209
200
  async validateConfiguration() {
210
201
  return typeof this.apiKey === "string" && this.apiKey.trim().length > 0;
@@ -1,8 +1,9 @@
1
- import { NetworkError, ProviderError } from "../types/index.js";
1
+ import { ProviderError } from "../types/index.js";
2
2
  import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../utils/errorClassifier.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { redactUrlCredentials } from "../utils/logSanitize.js";
5
5
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
6
+ import { buildLocalUnreachableErrorRule } from "./localRuntimeOpenAICompat.js";
6
7
  const LLAMACPP_DEFAULT_BASE_URL = "http://localhost:8080/v1";
7
8
  const LLAMACPP_PLACEHOLDER_KEY = "llamacpp";
8
9
  const getLlamaCppBaseURL = () => {
@@ -52,21 +53,9 @@ export class LlamaCppProvider extends OpenAIChatCompletionsProvider {
52
53
  return ["loaded-model"];
53
54
  }
54
55
  formatProviderError(error) {
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.
59
- const errorRecord = error;
60
- const cause = errorRecord?.cause ?? {};
61
- const code = (errorRecord?.code ?? cause?.code);
62
56
  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
- },
57
+ buildLocalUnreachableErrorRule(error, () => `llama.cpp server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
58
+ "Start it with: ./llama-server -m model.gguf --port 8080"),
70
59
  {
71
60
  match: (ctx) => /400/.test(ctx.message),
72
61
  errorClass: ProviderError,
@@ -1,8 +1,9 @@
1
- import { InvalidModelError, NetworkError } from "../types/index.js";
1
+ import { InvalidModelError } from "../types/index.js";
2
2
  import { classifyProviderError, DEFAULT_ERROR_RULES, } from "../utils/errorClassifier.js";
3
3
  import { logger } from "../utils/logger.js";
4
4
  import { redactUrlCredentials } from "../utils/logSanitize.js";
5
5
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
6
+ import { buildLocalUnreachableErrorRule } from "./localRuntimeOpenAICompat.js";
6
7
  const LM_STUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1";
7
8
  const LM_STUDIO_PLACEHOLDER_KEY = "lm-studio";
8
9
  const FALLBACK_MODEL = "local-model";
@@ -47,21 +48,9 @@ export class LMStudioProvider extends OpenAIChatCompletionsProvider {
47
48
  return FALLBACK_MODEL;
48
49
  }
49
50
  formatProviderError(error) {
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.
54
- const errorRecord = error;
55
- const cause = errorRecord?.cause ?? {};
56
- const code = (errorRecord?.code ?? cause?.code);
57
51
  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
- },
52
+ buildLocalUnreachableErrorRule(error, () => `LM Studio server not reachable at ${redactUrlCredentials(this.config.baseURL)}. ` +
53
+ `Open the LM Studio app, load a model, and click "Start Server".`),
65
54
  {
66
55
  match: (ctx) => /model_not_found|404/.test(ctx.message),
67
56
  errorClass: InvalidModelError,
@@ -0,0 +1,16 @@
1
+ import type { ProviderErrorRule } from "../types/index.js";
2
+ /**
3
+ * Shared error-rule builder for local-runtime OpenAI-compatible providers —
4
+ * Ollama, LM Studio, llama.cpp. All three had byte-identical connection-
5
+ * refused predicates (including the duck-typed `code`/`cause.code`
6
+ * extraction, since none of that lives on ProviderErrorContext) before this
7
+ * factored it out. A plain function, not a shared base class: the
8
+ * `neurolink/provider-base-class` lint rule requires every provider to
9
+ * extend BaseProvider or OpenAIChatCompletionsProvider directly, so the
10
+ * three keep their existing class hierarchy and just call this from their
11
+ * own `formatProviderError()`.
12
+ *
13
+ * `message` stays per-vendor (each provider's own exact wording, unchanged)
14
+ * — only the match predicate and errorClass were ever duplicated.
15
+ */
16
+ export declare function buildLocalUnreachableErrorRule(error: unknown, message: () => string): ProviderErrorRule;
@@ -0,0 +1,26 @@
1
+ import { NetworkError } from "../types/index.js";
2
+ /**
3
+ * Shared error-rule builder for local-runtime OpenAI-compatible providers —
4
+ * Ollama, LM Studio, llama.cpp. All three had byte-identical connection-
5
+ * refused predicates (including the duck-typed `code`/`cause.code`
6
+ * extraction, since none of that lives on ProviderErrorContext) before this
7
+ * factored it out. A plain function, not a shared base class: the
8
+ * `neurolink/provider-base-class` lint rule requires every provider to
9
+ * extend BaseProvider or OpenAIChatCompletionsProvider directly, so the
10
+ * three keep their existing class hierarchy and just call this from their
11
+ * own `formatProviderError()`.
12
+ *
13
+ * `message` stays per-vendor (each provider's own exact wording, unchanged)
14
+ * — only the match predicate and errorClass were ever duplicated.
15
+ */
16
+ export function buildLocalUnreachableErrorRule(error, message) {
17
+ const errorRecord = error;
18
+ const cause = errorRecord?.cause ?? {};
19
+ const code = (errorRecord?.code ?? cause?.code);
20
+ return {
21
+ match: (ctx) => code === "ECONNREFUSED" ||
22
+ /ECONNREFUSED|Failed to fetch|fetch failed/.test(ctx.message),
23
+ errorClass: NetworkError,
24
+ message,
25
+ };
26
+ }
@@ -6,6 +6,7 @@ import { logger } from "../../utils/logger.js";
6
6
  import { redactUrlCredentials } from "../../utils/logSanitize.js";
7
7
  import { createTimeoutController, parseTimeout, TimeoutError, } from "../../utils/timeout.js";
8
8
  import { OpenAIChatCompletionsProvider } from "../openaiChatCompletionsBase.js";
9
+ import { buildLocalUnreachableErrorRule } from "../localRuntimeOpenAICompat.js";
9
10
  import { stripTrailingSlash } from "../openaiChatCompletionsClient.js";
10
11
  import { DEFAULT_OLLAMA_MODEL } from "./constants.js";
11
12
  // Ollama serves its OpenAI-compatible surface under `/v1` (/v1/chat/completions,
@@ -97,16 +98,9 @@ export class OllamaProvider extends OpenAIChatCompletionsProvider {
97
98
  const responseBody = typeof errorRecord?.responseBody === "string"
98
99
  ? errorRecord.responseBody
99
100
  : "";
100
- const cause = errorRecord?.cause ?? {};
101
- const code = (errorRecord?.code ?? cause?.code);
102
101
  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
- },
102
+ buildLocalUnreachableErrorRule(error, () => `Cannot connect to Ollama at ${redactUrlCredentials(this.config.baseURL)}. ` +
103
+ `Install Ollama (https://ollama.com), start it with 'ollama serve', then try again.`),
110
104
  // The base client (buildAPIError) attaches statusCode + responseBody to
111
105
  // HTTP failures. Distinguish a genuine missing-model error (give 'ollama
112
106
  // pull' guidance) from a bare endpoint-mismatch 404 (wrong base URL / not
@@ -5,9 +5,9 @@
5
5
  * quirks (env var names, default model, error mapping).
6
6
  *
7
7
  * Currently extended by:
8
- * - OpenAICompatibleProvider (generic /v1/chat/completions backend)
9
- * - LiteLLMProvider (LiteLLM proxy server)
10
- * - DeepSeekProvider (api.deepseek.com)
8
+ * - OpenAICompatibleProvider (generic /v1/chat/completions backend)
9
+ * - LiteLLMProvider (LiteLLM proxy server)
10
+ * - ConfiguredOpenAICompatProvider (JSON-catalog providers, incl. deepseek)
11
11
  *
12
12
  * Subclasses provide:
13
13
  * - getProviderName() / getDefaultModel() / formatProviderError() (abstract)
@@ -5,9 +5,9 @@
5
5
  * quirks (env var names, default model, error mapping).
6
6
  *
7
7
  * Currently extended by:
8
- * - OpenAICompatibleProvider (generic /v1/chat/completions backend)
9
- * - LiteLLMProvider (LiteLLM proxy server)
10
- * - DeepSeekProvider (api.deepseek.com)
8
+ * - OpenAICompatibleProvider (generic /v1/chat/completions backend)
9
+ * - LiteLLMProvider (LiteLLM proxy server)
10
+ * - ConfiguredOpenAICompatProvider (JSON-catalog providers, incl. deepseek)
11
11
  *
12
12
  * Subclasses provide:
13
13
  * - getProviderName() / getDefaultModel() / formatProviderError() (abstract)
@@ -1055,7 +1055,14 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1055
1055
  usage: {
1056
1056
  input: inputTokens,
1057
1057
  output: outputTokens,
1058
- total: inputTokens + outputTokens,
1058
+ // `inputTokens` is the disjoint (uncached) figure — doGenerate
1059
+ // already subtracts the overlapping prompt_tokens_details.cached_tokens
1060
+ // subset before handing it to the loop — so cache tokens must be
1061
+ // added back in here to keep `total` billing-complete.
1062
+ total: inputTokens +
1063
+ outputTokens +
1064
+ loop.cacheReadTokens +
1065
+ loop.cacheWriteTokens,
1059
1066
  // doGenerate reads prompt_tokens_details.cached_tokens, and the loop
1060
1067
  // carries the counters out; discarding them here billed cached input
1061
1068
  // at the full rate in calculateCost and made cache effectiveness
@@ -1492,10 +1499,18 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
1492
1499
  if (!loopPromise) {
1493
1500
  const input = part.usage.inputTokens.total ?? 0;
1494
1501
  const output = part.usage.outputTokens.total ?? 0;
1502
+ // `total` is cache-INCLUSIVE (OpenAI-style prompt_tokens), so the
1503
+ // cached portion has to be split out here the same way
1504
+ // doGenerate/toDeferredUsage do — otherwise this branch bills the
1505
+ // cached tokens at the full input rate instead of the cheaper
1506
+ // cacheRead rate. Clamped like the other call sites in this file
1507
+ // in case a gateway reports cacheRead > total.
1508
+ const cacheRead = Math.min(part.usage.inputTokens.cacheRead ?? 0, input);
1495
1509
  resolveUsage({
1496
- promptTokens: input,
1510
+ promptTokens: input - cacheRead,
1497
1511
  completionTokens: output,
1498
1512
  totalTokens: input + output,
1513
+ ...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
1499
1514
  });
1500
1515
  resolveFinish(part.finishReason.unified);
1501
1516
  }