libsql-search 0.2.4 → 0.4.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.
package/README.md CHANGED
@@ -13,16 +13,17 @@ Use it when you want:
13
13
 
14
14
  - a small TypeScript library instead of a hosted search product
15
15
  - one search index shared across static-site builds and app routes
16
- - local or API-based embeddings behind the same indexing/search API
16
+ - local or hosted embeddings behind the same indexing/search API
17
17
  - direct control over table names, dimensions, content shape, and deployment
18
18
 
19
19
  ## What It Supports
20
20
 
21
21
  - Markdown indexing from local directories with frontmatter via `gray-matter`
22
22
  - libSQL/Turso storage and vector search
23
- - Embedding providers that exist in the code today: local
24
- `Xenova/all-MiniLM-L6-v2`, Google Gemini `text-embedding-004`, and OpenAI
25
- `text-embedding-3-small` and `text-embedding-3-large`
23
+ - Embedding providers: local `Xenova/all-MiniLM-L6-v2`, Cloudflare Workers AI
24
+ `@cf/baai/bge-m3`, Mistral `mistral-embed`, Google Gemini
25
+ `text-embedding-004`, and OpenAI `text-embedding-3-small` /
26
+ `text-embedding-3-large`
26
27
  - npm distribution plus JSR publishing
27
28
 
28
29
  ## Install
@@ -97,6 +98,8 @@ Important behavior:
97
98
  - Call `createTable()` before indexing or searching.
98
99
  - Keep dimensions aligned across table creation, indexing, and search queries.
99
100
  - `indexContent()` clears existing rows before rebuilding the index.
101
+ - `local` is the default offline provider; Cloudflare is the recommended hosted
102
+ option. Cloudflare and Mistral use 1024 dimensions.
100
103
 
101
104
  ## Core API
102
105
 
package/dist/index.cjs CHANGED
@@ -12,6 +12,11 @@ const GEMINI_MODEL = "text-embedding-004";
12
12
  const OPENAI_SMALL_MODEL = "text-embedding-3-small";
13
13
  const OPENAI_LARGE_MODEL = "text-embedding-3-large";
14
14
  const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
15
+ const MISTRAL_MODEL = "mistral-embed";
16
+ const MISTRAL_EMBEDDINGS_URL = "https://api.mistral.ai/v1/embeddings";
17
+ const MISTRAL_DIMENSIONS = 1024;
18
+ const CLOUDFLARE_MODEL = "@cf/baai/bge-m3";
19
+ const CLOUDFLARE_DIMENSIONS = 1024;
15
20
  const localModelCacheByModel = /* @__PURE__ */ new Map();
16
21
  function getEnvironmentVariable(name) {
17
22
  const runtime = globalThis;
@@ -51,6 +56,8 @@ function resolveProviderName(provider) {
51
56
  case "local":
52
57
  case "gemini":
53
58
  case "openai":
59
+ case "mistral":
60
+ case "cloudflare":
54
61
  return provider ?? "local";
55
62
  default:
56
63
  throw new Error(`Unknown embedding provider: ${String(provider)}`);
@@ -71,9 +78,16 @@ function truncateTexts(texts, maxLength) {
71
78
  function getOpenAIModel(dimensions) {
72
79
  return dimensions <= 1536 ? OPENAI_SMALL_MODEL : OPENAI_LARGE_MODEL;
73
80
  }
81
+ function getOptionalTrimmedCredential(value) {
82
+ const trimmed = value?.trim();
83
+ return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
84
+ }
74
85
  function hasOwnProperty(value, property) {
75
86
  return Object.prototype.hasOwnProperty.call(value, property);
76
87
  }
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null;
90
+ }
77
91
  function redactErrorText(value, exactSecrets = []) {
78
92
  let raw = value instanceof Error ? value.message : String(value);
79
93
  for (const secret of exactSecrets) {
@@ -97,6 +111,16 @@ function providerError(provider, message, cause, exactSecrets = []) {
97
111
  function getResponseHeader(response, name) {
98
112
  return response.headers.get(name) ?? void 0;
99
113
  }
114
+ function getSafeResponseHeader(response, name) {
115
+ const value = getResponseHeader(response, name)?.trim();
116
+ if (!value) {
117
+ return void 0;
118
+ }
119
+ return value.replace(/[^A-Za-z0-9._:-]/g, "").slice(0, 128);
120
+ }
121
+ function createCloudflareEmbeddingsUrl(accountId) {
122
+ return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1/embeddings`;
123
+ }
100
124
  async function withTimeout(provider, operation, timeoutMs, run, parentSignal, exactSecrets = []) {
101
125
  if (parentSignal?.aborted) {
102
126
  throw providerError(provider, `${operation} was aborted`);
@@ -225,12 +249,44 @@ function createProviderMetadata(provider, dimensions) {
225
249
  dimensions,
226
250
  batch: Object.freeze({ mode: "native", maxSize: 2048 })
227
251
  });
252
+ case "mistral":
253
+ return Object.freeze({
254
+ name: "mistral",
255
+ model: MISTRAL_MODEL,
256
+ dimensions: MISTRAL_DIMENSIONS,
257
+ batch: Object.freeze({ mode: "native" })
258
+ });
259
+ case "cloudflare":
260
+ return Object.freeze({
261
+ name: "cloudflare",
262
+ model: CLOUDFLARE_MODEL,
263
+ dimensions: CLOUDFLARE_DIMENSIONS,
264
+ batch: Object.freeze({ mode: "native" })
265
+ });
228
266
  }
229
267
  }
230
268
  function getEffectiveDimensions(provider, dimensions) {
231
269
  if (provider === "gemini") {
232
270
  return DEFAULT_DIMENSIONS;
233
271
  }
272
+ if (provider === "mistral") {
273
+ if (dimensions !== void 0 && dimensions !== MISTRAL_DIMENSIONS) {
274
+ throw providerError(
275
+ "mistral",
276
+ `${MISTRAL_MODEL} returns ${MISTRAL_DIMENSIONS} dimensions; received dimensions ${dimensions}`
277
+ );
278
+ }
279
+ return MISTRAL_DIMENSIONS;
280
+ }
281
+ if (provider === "cloudflare") {
282
+ if (dimensions !== void 0 && dimensions !== CLOUDFLARE_DIMENSIONS) {
283
+ throw providerError(
284
+ "cloudflare",
285
+ `@cf/baai/bge-m3 returns ${CLOUDFLARE_DIMENSIONS} dimensions; received dimensions ${dimensions}`
286
+ );
287
+ }
288
+ return CLOUDFLARE_DIMENSIONS;
289
+ }
234
290
  return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
235
291
  }
236
292
  function assertBatchSize(metadata, count) {
@@ -329,6 +385,52 @@ class GeminiEmbeddingProvider {
329
385
  );
330
386
  }
331
387
  }
388
+ async function requestOpenAICompatibleEmbeddings(request) {
389
+ const body = {
390
+ input: request.texts,
391
+ model: request.model
392
+ };
393
+ if (request.dimensions !== void 0) {
394
+ body.dimensions = request.dimensions;
395
+ }
396
+ if (request.encodingFormat !== void 0) {
397
+ body.encoding_format = request.encodingFormat;
398
+ }
399
+ const response = await fetch(request.endpoint, {
400
+ method: "POST",
401
+ headers: {
402
+ "Content-Type": "application/json",
403
+ "Authorization": `Bearer ${request.apiKey}`
404
+ },
405
+ signal: request.signal,
406
+ body: JSON.stringify(body)
407
+ });
408
+ if (!response.ok) {
409
+ const requestId = getSafeResponseHeader(response, "x-request-id");
410
+ const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
411
+ throw new Error(context);
412
+ }
413
+ const data = await response.json();
414
+ if (!Array.isArray(data.data)) {
415
+ throw new Error(`${request.responseLabel} response did not include a data array`);
416
+ }
417
+ return data.data.map((item) => {
418
+ if (!isRecord(item)) {
419
+ throw new Error(`${request.responseLabel} response item was not an object`);
420
+ }
421
+ const embedding = item.embedding;
422
+ if (!Array.isArray(embedding)) {
423
+ throw new Error(`${request.responseLabel} response item did not include an embedding array`);
424
+ }
425
+ const parsed = { embedding };
426
+ if (hasOwnProperty(item, "index")) {
427
+ parsed.index = item.index;
428
+ } else if (request.requireIndex) {
429
+ throw new Error(`${request.responseLabel} response item did not include an index`);
430
+ }
431
+ return parsed;
432
+ });
433
+ }
332
434
  class OpenAIEmbeddingProvider {
333
435
  metadata;
334
436
  #apiKey;
@@ -348,34 +450,113 @@ class OpenAIEmbeddingProvider {
348
450
  "openai",
349
451
  "API request",
350
452
  this.#timeoutMs,
453
+ async (signal) => requestOpenAICompatibleEmbeddings({
454
+ responseLabel: "OpenAI",
455
+ endpoint: OPENAI_EMBEDDINGS_URL,
456
+ apiKey: this.#apiKey,
457
+ model: this.metadata.model,
458
+ texts,
459
+ signal,
460
+ dimensions: this.metadata.dimensions
461
+ }),
462
+ options.signal,
463
+ [this.#apiKey]
464
+ );
465
+ return createEmbeddingBatchResult(
466
+ this.metadata,
467
+ intent,
468
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
469
+ );
470
+ }
471
+ }
472
+ class MistralEmbeddingProvider {
473
+ metadata;
474
+ #apiKey;
475
+ #timeoutMs;
476
+ constructor(metadata, apiKey, timeoutMs) {
477
+ this.metadata = metadata;
478
+ this.#apiKey = apiKey;
479
+ this.#timeoutMs = timeoutMs;
480
+ }
481
+ async embed(texts, options = {}) {
482
+ const intent = resolveIntent(options.intent);
483
+ if (texts.length === 0) {
484
+ return createEmbeddingBatchResult(this.metadata, intent, []);
485
+ }
486
+ assertBatchSize(this.metadata, texts.length);
487
+ const items = await withTimeout(
488
+ "mistral",
489
+ "API request",
490
+ this.#timeoutMs,
491
+ async (signal) => requestOpenAICompatibleEmbeddings({
492
+ responseLabel: "Mistral",
493
+ endpoint: MISTRAL_EMBEDDINGS_URL,
494
+ apiKey: this.#apiKey,
495
+ model: this.metadata.model,
496
+ texts,
497
+ signal,
498
+ encodingFormat: "float",
499
+ requireIndex: true
500
+ }),
501
+ options.signal,
502
+ [this.#apiKey]
503
+ );
504
+ return createEmbeddingBatchResult(
505
+ this.metadata,
506
+ intent,
507
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
508
+ );
509
+ }
510
+ }
511
+ class CloudflareEmbeddingProvider {
512
+ metadata;
513
+ #accountId;
514
+ #apiToken;
515
+ #timeoutMs;
516
+ constructor(metadata, accountId, apiToken, timeoutMs) {
517
+ this.metadata = metadata;
518
+ this.#accountId = accountId;
519
+ this.#apiToken = apiToken;
520
+ this.#timeoutMs = timeoutMs;
521
+ }
522
+ async embed(texts, options = {}) {
523
+ const intent = resolveIntent(options.intent);
524
+ if (texts.length === 0) {
525
+ return createEmbeddingBatchResult(this.metadata, intent, []);
526
+ }
527
+ assertBatchSize(this.metadata, texts.length);
528
+ const endpoint = createCloudflareEmbeddingsUrl(this.#accountId);
529
+ const items = await withTimeout(
530
+ "cloudflare",
531
+ "API request",
532
+ this.#timeoutMs,
351
533
  async (signal) => {
352
- const response = await fetch(OPENAI_EMBEDDINGS_URL, {
534
+ const response = await fetch(endpoint, {
353
535
  method: "POST",
354
536
  headers: {
355
537
  "Content-Type": "application/json",
356
- "Authorization": `Bearer ${this.#apiKey}`
538
+ "Authorization": `Bearer ${this.#apiToken}`
357
539
  },
358
540
  signal,
359
541
  body: JSON.stringify({
360
- input: texts,
361
542
  model: this.metadata.model,
362
- dimensions: this.metadata.dimensions
543
+ input: texts
363
544
  })
364
545
  });
365
546
  if (!response.ok) {
366
- const requestId = getResponseHeader(response, "x-request-id");
367
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
547
+ const cfRay = getSafeResponseHeader(response, "cf-ray");
548
+ const context = cfRay ? ` status ${response.status}, cf-ray ${cfRay}` : ` status ${response.status}`;
368
549
  throw new Error(context);
369
550
  }
370
551
  const data = await response.json();
371
552
  if (!Array.isArray(data.data)) {
372
- throw new Error("OpenAI response did not include a data array");
553
+ throw new Error("Cloudflare response did not include a data array");
373
554
  }
374
555
  return data.data.map((item) => {
375
556
  const typed = item;
376
557
  const embedding = typed.embedding;
377
558
  if (!Array.isArray(embedding)) {
378
- throw new Error("OpenAI response item did not include an embedding array");
559
+ throw new Error("Cloudflare response item did not include an embedding array");
379
560
  }
380
561
  const parsed = { embedding };
381
562
  if (hasOwnProperty(typed, "index")) {
@@ -385,12 +566,12 @@ class OpenAIEmbeddingProvider {
385
566
  });
386
567
  },
387
568
  options.signal,
388
- [this.#apiKey]
569
+ [this.#apiToken, this.#accountId, encodeURIComponent(this.#accountId)]
389
570
  );
390
571
  return createEmbeddingBatchResult(
391
572
  this.metadata,
392
573
  intent,
393
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
574
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "cloudflare")
394
575
  );
395
576
  }
396
577
  }
@@ -415,6 +596,30 @@ function createEmbeddingProvider(options = {}) {
415
596
  }
416
597
  return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
417
598
  }
599
+ case "mistral": {
600
+ const key = getOptionalTrimmedCredential(
601
+ options.apiKey ?? getEnvironmentVariable("MISTRAL_API_KEY")
602
+ );
603
+ if (!key) {
604
+ throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
605
+ }
606
+ return new MistralEmbeddingProvider(metadata, key, timeoutMs);
607
+ }
608
+ case "cloudflare": {
609
+ const accountId = getOptionalTrimmedCredential(
610
+ options.accountId ?? getEnvironmentVariable("CLOUDFLARE_ACCOUNT_ID")
611
+ );
612
+ const apiToken = getOptionalTrimmedCredential(
613
+ options.apiToken ?? getEnvironmentVariable("CLOUDFLARE_API_TOKEN")
614
+ );
615
+ if (!accountId) {
616
+ throw new Error("CLOUDFLARE_ACCOUNT_ID is required for Cloudflare embeddings");
617
+ }
618
+ if (!apiToken) {
619
+ throw new Error("CLOUDFLARE_API_TOKEN is required for Cloudflare embeddings");
620
+ }
621
+ return new CloudflareEmbeddingProvider(metadata, accountId, apiToken, timeoutMs);
622
+ }
418
623
  }
419
624
  }
420
625
  function getEmbeddingProviderMetadata(options = {}) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Client } from '@libsql/client';
2
2
 
3
- type EmbeddingProvider = 'local' | 'gemini' | 'openai';
3
+ type EmbeddingProvider = 'local' | 'gemini' | 'openai' | 'mistral' | 'cloudflare';
4
4
  type EmbeddingIntent = 'document' | 'query';
5
5
  type EmbeddingBatchMode = 'native' | 'sequential';
6
6
  interface EmbeddingBatchBehavior {
@@ -31,6 +31,8 @@ interface EmbeddingBatchResult {
31
31
  interface EmbeddingOptions {
32
32
  provider?: EmbeddingProvider;
33
33
  apiKey?: string;
34
+ accountId?: string;
35
+ apiToken?: string;
34
36
  dimensions?: number;
35
37
  maxLength?: number;
36
38
  intent?: EmbeddingIntent;
package/dist/index.esm.js CHANGED
@@ -10,6 +10,11 @@ const GEMINI_MODEL = "text-embedding-004";
10
10
  const OPENAI_SMALL_MODEL = "text-embedding-3-small";
11
11
  const OPENAI_LARGE_MODEL = "text-embedding-3-large";
12
12
  const OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings";
13
+ const MISTRAL_MODEL = "mistral-embed";
14
+ const MISTRAL_EMBEDDINGS_URL = "https://api.mistral.ai/v1/embeddings";
15
+ const MISTRAL_DIMENSIONS = 1024;
16
+ const CLOUDFLARE_MODEL = "@cf/baai/bge-m3";
17
+ const CLOUDFLARE_DIMENSIONS = 1024;
13
18
  const localModelCacheByModel = /* @__PURE__ */ new Map();
14
19
  function getEnvironmentVariable(name) {
15
20
  const runtime = globalThis;
@@ -49,6 +54,8 @@ function resolveProviderName(provider) {
49
54
  case "local":
50
55
  case "gemini":
51
56
  case "openai":
57
+ case "mistral":
58
+ case "cloudflare":
52
59
  return provider ?? "local";
53
60
  default:
54
61
  throw new Error(`Unknown embedding provider: ${String(provider)}`);
@@ -69,9 +76,16 @@ function truncateTexts(texts, maxLength) {
69
76
  function getOpenAIModel(dimensions) {
70
77
  return dimensions <= 1536 ? OPENAI_SMALL_MODEL : OPENAI_LARGE_MODEL;
71
78
  }
79
+ function getOptionalTrimmedCredential(value) {
80
+ const trimmed = value?.trim();
81
+ return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
82
+ }
72
83
  function hasOwnProperty(value, property) {
73
84
  return Object.prototype.hasOwnProperty.call(value, property);
74
85
  }
86
+ function isRecord(value) {
87
+ return typeof value === "object" && value !== null;
88
+ }
75
89
  function redactErrorText(value, exactSecrets = []) {
76
90
  let raw = value instanceof Error ? value.message : String(value);
77
91
  for (const secret of exactSecrets) {
@@ -95,6 +109,16 @@ function providerError(provider, message, cause, exactSecrets = []) {
95
109
  function getResponseHeader(response, name) {
96
110
  return response.headers.get(name) ?? void 0;
97
111
  }
112
+ function getSafeResponseHeader(response, name) {
113
+ const value = getResponseHeader(response, name)?.trim();
114
+ if (!value) {
115
+ return void 0;
116
+ }
117
+ return value.replace(/[^A-Za-z0-9._:-]/g, "").slice(0, 128);
118
+ }
119
+ function createCloudflareEmbeddingsUrl(accountId) {
120
+ return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/v1/embeddings`;
121
+ }
98
122
  async function withTimeout(provider, operation, timeoutMs, run, parentSignal, exactSecrets = []) {
99
123
  if (parentSignal?.aborted) {
100
124
  throw providerError(provider, `${operation} was aborted`);
@@ -223,12 +247,44 @@ function createProviderMetadata(provider, dimensions) {
223
247
  dimensions,
224
248
  batch: Object.freeze({ mode: "native", maxSize: 2048 })
225
249
  });
250
+ case "mistral":
251
+ return Object.freeze({
252
+ name: "mistral",
253
+ model: MISTRAL_MODEL,
254
+ dimensions: MISTRAL_DIMENSIONS,
255
+ batch: Object.freeze({ mode: "native" })
256
+ });
257
+ case "cloudflare":
258
+ return Object.freeze({
259
+ name: "cloudflare",
260
+ model: CLOUDFLARE_MODEL,
261
+ dimensions: CLOUDFLARE_DIMENSIONS,
262
+ batch: Object.freeze({ mode: "native" })
263
+ });
226
264
  }
227
265
  }
228
266
  function getEffectiveDimensions(provider, dimensions) {
229
267
  if (provider === "gemini") {
230
268
  return DEFAULT_DIMENSIONS;
231
269
  }
270
+ if (provider === "mistral") {
271
+ if (dimensions !== void 0 && dimensions !== MISTRAL_DIMENSIONS) {
272
+ throw providerError(
273
+ "mistral",
274
+ `${MISTRAL_MODEL} returns ${MISTRAL_DIMENSIONS} dimensions; received dimensions ${dimensions}`
275
+ );
276
+ }
277
+ return MISTRAL_DIMENSIONS;
278
+ }
279
+ if (provider === "cloudflare") {
280
+ if (dimensions !== void 0 && dimensions !== CLOUDFLARE_DIMENSIONS) {
281
+ throw providerError(
282
+ "cloudflare",
283
+ `@cf/baai/bge-m3 returns ${CLOUDFLARE_DIMENSIONS} dimensions; received dimensions ${dimensions}`
284
+ );
285
+ }
286
+ return CLOUDFLARE_DIMENSIONS;
287
+ }
232
288
  return getPositiveInteger(dimensions ?? DEFAULT_DIMENSIONS, "dimensions");
233
289
  }
234
290
  function assertBatchSize(metadata, count) {
@@ -327,6 +383,52 @@ class GeminiEmbeddingProvider {
327
383
  );
328
384
  }
329
385
  }
386
+ async function requestOpenAICompatibleEmbeddings(request) {
387
+ const body = {
388
+ input: request.texts,
389
+ model: request.model
390
+ };
391
+ if (request.dimensions !== void 0) {
392
+ body.dimensions = request.dimensions;
393
+ }
394
+ if (request.encodingFormat !== void 0) {
395
+ body.encoding_format = request.encodingFormat;
396
+ }
397
+ const response = await fetch(request.endpoint, {
398
+ method: "POST",
399
+ headers: {
400
+ "Content-Type": "application/json",
401
+ "Authorization": `Bearer ${request.apiKey}`
402
+ },
403
+ signal: request.signal,
404
+ body: JSON.stringify(body)
405
+ });
406
+ if (!response.ok) {
407
+ const requestId = getSafeResponseHeader(response, "x-request-id");
408
+ const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
409
+ throw new Error(context);
410
+ }
411
+ const data = await response.json();
412
+ if (!Array.isArray(data.data)) {
413
+ throw new Error(`${request.responseLabel} response did not include a data array`);
414
+ }
415
+ return data.data.map((item) => {
416
+ if (!isRecord(item)) {
417
+ throw new Error(`${request.responseLabel} response item was not an object`);
418
+ }
419
+ const embedding = item.embedding;
420
+ if (!Array.isArray(embedding)) {
421
+ throw new Error(`${request.responseLabel} response item did not include an embedding array`);
422
+ }
423
+ const parsed = { embedding };
424
+ if (hasOwnProperty(item, "index")) {
425
+ parsed.index = item.index;
426
+ } else if (request.requireIndex) {
427
+ throw new Error(`${request.responseLabel} response item did not include an index`);
428
+ }
429
+ return parsed;
430
+ });
431
+ }
330
432
  class OpenAIEmbeddingProvider {
331
433
  metadata;
332
434
  #apiKey;
@@ -346,34 +448,113 @@ class OpenAIEmbeddingProvider {
346
448
  "openai",
347
449
  "API request",
348
450
  this.#timeoutMs,
451
+ async (signal) => requestOpenAICompatibleEmbeddings({
452
+ responseLabel: "OpenAI",
453
+ endpoint: OPENAI_EMBEDDINGS_URL,
454
+ apiKey: this.#apiKey,
455
+ model: this.metadata.model,
456
+ texts,
457
+ signal,
458
+ dimensions: this.metadata.dimensions
459
+ }),
460
+ options.signal,
461
+ [this.#apiKey]
462
+ );
463
+ return createEmbeddingBatchResult(
464
+ this.metadata,
465
+ intent,
466
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
467
+ );
468
+ }
469
+ }
470
+ class MistralEmbeddingProvider {
471
+ metadata;
472
+ #apiKey;
473
+ #timeoutMs;
474
+ constructor(metadata, apiKey, timeoutMs) {
475
+ this.metadata = metadata;
476
+ this.#apiKey = apiKey;
477
+ this.#timeoutMs = timeoutMs;
478
+ }
479
+ async embed(texts, options = {}) {
480
+ const intent = resolveIntent(options.intent);
481
+ if (texts.length === 0) {
482
+ return createEmbeddingBatchResult(this.metadata, intent, []);
483
+ }
484
+ assertBatchSize(this.metadata, texts.length);
485
+ const items = await withTimeout(
486
+ "mistral",
487
+ "API request",
488
+ this.#timeoutMs,
489
+ async (signal) => requestOpenAICompatibleEmbeddings({
490
+ responseLabel: "Mistral",
491
+ endpoint: MISTRAL_EMBEDDINGS_URL,
492
+ apiKey: this.#apiKey,
493
+ model: this.metadata.model,
494
+ texts,
495
+ signal,
496
+ encodingFormat: "float",
497
+ requireIndex: true
498
+ }),
499
+ options.signal,
500
+ [this.#apiKey]
501
+ );
502
+ return createEmbeddingBatchResult(
503
+ this.metadata,
504
+ intent,
505
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
506
+ );
507
+ }
508
+ }
509
+ class CloudflareEmbeddingProvider {
510
+ metadata;
511
+ #accountId;
512
+ #apiToken;
513
+ #timeoutMs;
514
+ constructor(metadata, accountId, apiToken, timeoutMs) {
515
+ this.metadata = metadata;
516
+ this.#accountId = accountId;
517
+ this.#apiToken = apiToken;
518
+ this.#timeoutMs = timeoutMs;
519
+ }
520
+ async embed(texts, options = {}) {
521
+ const intent = resolveIntent(options.intent);
522
+ if (texts.length === 0) {
523
+ return createEmbeddingBatchResult(this.metadata, intent, []);
524
+ }
525
+ assertBatchSize(this.metadata, texts.length);
526
+ const endpoint = createCloudflareEmbeddingsUrl(this.#accountId);
527
+ const items = await withTimeout(
528
+ "cloudflare",
529
+ "API request",
530
+ this.#timeoutMs,
349
531
  async (signal) => {
350
- const response = await fetch(OPENAI_EMBEDDINGS_URL, {
532
+ const response = await fetch(endpoint, {
351
533
  method: "POST",
352
534
  headers: {
353
535
  "Content-Type": "application/json",
354
- "Authorization": `Bearer ${this.#apiKey}`
536
+ "Authorization": `Bearer ${this.#apiToken}`
355
537
  },
356
538
  signal,
357
539
  body: JSON.stringify({
358
- input: texts,
359
540
  model: this.metadata.model,
360
- dimensions: this.metadata.dimensions
541
+ input: texts
361
542
  })
362
543
  });
363
544
  if (!response.ok) {
364
- const requestId = getResponseHeader(response, "x-request-id");
365
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
545
+ const cfRay = getSafeResponseHeader(response, "cf-ray");
546
+ const context = cfRay ? ` status ${response.status}, cf-ray ${cfRay}` : ` status ${response.status}`;
366
547
  throw new Error(context);
367
548
  }
368
549
  const data = await response.json();
369
550
  if (!Array.isArray(data.data)) {
370
- throw new Error("OpenAI response did not include a data array");
551
+ throw new Error("Cloudflare response did not include a data array");
371
552
  }
372
553
  return data.data.map((item) => {
373
554
  const typed = item;
374
555
  const embedding = typed.embedding;
375
556
  if (!Array.isArray(embedding)) {
376
- throw new Error("OpenAI response item did not include an embedding array");
557
+ throw new Error("Cloudflare response item did not include an embedding array");
377
558
  }
378
559
  const parsed = { embedding };
379
560
  if (hasOwnProperty(typed, "index")) {
@@ -383,12 +564,12 @@ class OpenAIEmbeddingProvider {
383
564
  });
384
565
  },
385
566
  options.signal,
386
- [this.#apiKey]
567
+ [this.#apiToken, this.#accountId, encodeURIComponent(this.#accountId)]
387
568
  );
388
569
  return createEmbeddingBatchResult(
389
570
  this.metadata,
390
571
  intent,
391
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
572
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "cloudflare")
392
573
  );
393
574
  }
394
575
  }
@@ -413,6 +594,30 @@ function createEmbeddingProvider(options = {}) {
413
594
  }
414
595
  return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
415
596
  }
597
+ case "mistral": {
598
+ const key = getOptionalTrimmedCredential(
599
+ options.apiKey ?? getEnvironmentVariable("MISTRAL_API_KEY")
600
+ );
601
+ if (!key) {
602
+ throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
603
+ }
604
+ return new MistralEmbeddingProvider(metadata, key, timeoutMs);
605
+ }
606
+ case "cloudflare": {
607
+ const accountId = getOptionalTrimmedCredential(
608
+ options.accountId ?? getEnvironmentVariable("CLOUDFLARE_ACCOUNT_ID")
609
+ );
610
+ const apiToken = getOptionalTrimmedCredential(
611
+ options.apiToken ?? getEnvironmentVariable("CLOUDFLARE_API_TOKEN")
612
+ );
613
+ if (!accountId) {
614
+ throw new Error("CLOUDFLARE_ACCOUNT_ID is required for Cloudflare embeddings");
615
+ }
616
+ if (!apiToken) {
617
+ throw new Error("CLOUDFLARE_API_TOKEN is required for Cloudflare embeddings");
618
+ }
619
+ return new CloudflareEmbeddingProvider(metadata, accountId, apiToken, timeoutMs);
620
+ }
416
621
  }
417
622
  }
418
623
  function getEmbeddingProviderMetadata(options = {}) {
package/docs/API.md CHANGED
@@ -210,7 +210,8 @@ Provider metadata includes:
210
210
  - `batch.maxSize`, when the provider has a hard maximum
211
211
 
212
212
  Hosted provider clients are scoped to their options. The library does not reuse
213
- a Gemini or OpenAI client created with a different API key or configuration.
213
+ a Cloudflare, Mistral, Gemini, or OpenAI client created with different
214
+ credentials or configuration.
214
215
 
215
216
  ### `getEmbeddingProviderMetadata(options?)`
216
217
 
@@ -238,7 +239,7 @@ Provider clients return:
238
239
  ```ts
239
240
  interface EmbeddingBatchResult {
240
241
  embeddings: number[][];
241
- provider: "local" | "gemini" | "openai";
242
+ provider: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
242
243
  model: string;
243
244
  dimensions: number;
244
245
  intent: "document" | "query";
@@ -254,8 +255,10 @@ cardinality, dimensions, finite numeric values, and indexed batch ordering.
254
255
 
255
256
  ```ts
256
257
  interface EmbeddingOptions {
257
- provider?: "local" | "gemini" | "openai";
258
+ provider?: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
258
259
  apiKey?: string;
260
+ accountId?: string;
261
+ apiToken?: string;
259
262
  dimensions?: number;
260
263
  maxLength?: number;
261
264
  intent?: "document" | "query";
@@ -264,6 +267,11 @@ interface EmbeddingOptions {
264
267
  }
265
268
  ```
266
269
 
270
+ `apiKey` is used by Mistral, Gemini, and OpenAI. It falls back to
271
+ `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY` for those providers.
272
+ Cloudflare uses `accountId` and `apiToken`, which fall back to
273
+ `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`.
274
+
267
275
  ### `padEmbedding(embedding, targetDimensions)`
268
276
 
269
277
  Pads or truncates an embedding array to the requested length.
@@ -127,14 +127,27 @@ const client = createClient({
127
127
  authToken: process.env.TURSO_AUTH_TOKEN!,
128
128
  });
129
129
 
130
- await createTable(client, "articles", 768);
130
+ const embeddingProvider =
131
+ process.env.EMBEDDING_PROVIDER as
132
+ | "local"
133
+ | "cloudflare"
134
+ | "mistral"
135
+ | "gemini"
136
+ | "openai"
137
+ | undefined;
138
+ const embeddingDimensions =
139
+ embeddingProvider === "cloudflare" || embeddingProvider === "mistral"
140
+ ? 1024
141
+ : 768;
142
+
143
+ await createTable(client, "articles", embeddingDimensions);
131
144
 
132
145
  await indexContent({
133
146
  client,
134
147
  contentPath: "./content",
135
148
  embeddingOptions: {
136
- provider: process.env.EMBEDDING_PROVIDER as "local" | "gemini" | "openai" | undefined,
137
- dimensions: 768,
149
+ provider: embeddingProvider,
150
+ dimensions: embeddingDimensions,
138
151
  },
139
152
  });
140
153
  ```
package/docs/PROVIDERS.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Embedding Providers
2
2
 
3
- `libsql-search` currently supports three embedding providers:
3
+ `libsql-search` currently supports five embedding providers:
4
4
 
5
5
  - `local`
6
+ - `cloudflare`
7
+ - `mistral`
6
8
  - `gemini`
7
9
  - `openai`
8
10
 
@@ -14,8 +16,10 @@ query time.
14
16
 
15
17
  ```ts
16
18
  interface EmbeddingOptions {
17
- provider?: "local" | "gemini" | "openai";
19
+ provider?: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
18
20
  apiKey?: string;
21
+ accountId?: string;
22
+ apiToken?: string;
19
23
  dimensions?: number;
20
24
  maxLength?: number;
21
25
  intent?: "document" | "query";
@@ -30,8 +34,10 @@ interface EmbeddingOptions {
30
34
  - `intent` can be `"document"` or `"query"`; indexing defaults to
31
35
  `"document"` and search defaults to `"query"` unless explicitly set
32
36
  - `timeoutMs` defaults to `30000`
33
- - `apiKey` is optional in code, but required for hosted providers unless the
34
- matching environment variable is available
37
+ - `apiKey` is used by Mistral, Gemini, and OpenAI and falls back to
38
+ `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY`
39
+ - `accountId` and `apiToken` are used by Cloudflare and fall back to
40
+ `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`
35
41
 
36
42
  ## Provider Contract
37
43
 
@@ -39,7 +45,7 @@ Each provider exposes immutable metadata:
39
45
 
40
46
  ```ts
41
47
  interface EmbeddingProviderMetadata {
42
- name: "local" | "gemini" | "openai";
48
+ name: "local" | "cloudflare" | "mistral" | "gemini" | "openai";
43
49
  model: string;
44
50
  dimensions: number;
45
51
  batch: {
@@ -78,9 +84,9 @@ Lower-level provider clients return an `EmbeddingBatchResult` with the validated
78
84
  vectors plus provider, model, dimensions, and intent. The compatibility helpers
79
85
  `generateEmbedding()` and `generateEmbeddings()` return only arrays.
80
86
 
81
- Gemini and OpenAI clients are scoped to their current options. They are not
82
- cached globally across different credentials or configurations. The local Xenova
83
- model can be cached by model name.
87
+ Cloudflare, Mistral, Gemini, and OpenAI clients are scoped to their current
88
+ options. They are not cached globally across different credentials or
89
+ configurations. The local Xenova model can be cached by model name.
84
90
 
85
91
  Hosted provider failures are reported with bounded provider/status/request-id
86
92
  context and without raw upstream bodies, credentials, Authorization headers, or
@@ -108,6 +114,63 @@ Notes:
108
114
  - batch metadata is `{ mode: "sequential" }`
109
115
  - the first run downloads the model and can take longer on a fresh machine
110
116
  - no API key is required
117
+ - this remains the default provider for backward compatibility and offline use
118
+
119
+ ## Cloudflare Workers AI
120
+
121
+ Provider value: `cloudflare`
122
+
123
+ Cloudflare is the recommended hosted provider for low-cost Markdown search.
124
+ It uses Workers AI `@cf/baai/bge-m3` through Cloudflare's OpenAI-compatible
125
+ embeddings endpoint.
126
+
127
+ ```ts
128
+ embeddingOptions: {
129
+ provider: "cloudflare",
130
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
131
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
132
+ }
133
+ ```
134
+
135
+ Behavior:
136
+
137
+ - if `accountId` is omitted, the library reads `CLOUDFLARE_ACCOUNT_ID`
138
+ - if `apiToken` is omitted, the library reads `CLOUDFLARE_API_TOKEN`
139
+ - blank Cloudflare credentials are treated as missing
140
+ - `@cf/baai/bge-m3` returns 1024 dimensions
141
+ - metadata reports `@cf/baai/bge-m3` and 1024 dimensions without requiring
142
+ credentials
143
+ - batch metadata is `{ mode: "native" }`
144
+ - response items are reordered by provider-supplied index before being returned
145
+ - Cloudflare does not accept custom dimensions in this provider; use
146
+ `createTable(client, "articles", 1024)` for Cloudflare-backed indexes
147
+
148
+ ## Mistral
149
+
150
+ Provider value: `mistral`
151
+
152
+ Mistral uses the hosted `mistral-embed` model through
153
+ `https://api.mistral.ai/v1/embeddings`.
154
+
155
+ ```ts
156
+ embeddingOptions: {
157
+ provider: "mistral",
158
+ apiKey: process.env.MISTRAL_API_KEY,
159
+ }
160
+ ```
161
+
162
+ Behavior:
163
+
164
+ - if `apiKey` is omitted, the library reads `MISTRAL_API_KEY`
165
+ - blank Mistral credentials are treated as missing
166
+ - `mistral-embed` returns 1024 dimensions
167
+ - metadata reports `mistral-embed` and 1024 dimensions without requiring
168
+ credentials
169
+ - batch metadata is `{ mode: "native" }`
170
+ - request bodies send `encoding_format: "float"`
171
+ - response items are reordered by provider-supplied index before being returned
172
+ - Mistral does not accept custom dimensions in this provider; use
173
+ `createTable(client, "articles", 1024)` for Mistral-backed indexes
111
174
 
112
175
  ## Gemini
113
176
 
@@ -156,7 +219,9 @@ Behavior:
156
219
 
157
220
  ## Dimension Guidelines
158
221
 
159
- - `768` is the easiest cross-provider target in the current implementation
222
+ - `local` defaults to `768`
223
+ - `cloudflare` is fixed at `1024`
224
+ - `mistral` is fixed at `1024`
160
225
  - local embeddings are padded from 384 to your target size
161
226
  - Gemini stays at 768
162
227
  - OpenAI can be used at 1536 or 3072, or another supported OpenAI dimension
package/docs/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  This directory holds the longer-form reference material for `libsql-search`.
4
4
  Start with the page that matches the job you are doing:
5
5
 
6
- - [Provider guide](./PROVIDERS.md): local, Gemini, and OpenAI embedding options,
7
- dimensions, and API key behavior
6
+ - [Provider guide](./PROVIDERS.md): local, Cloudflare, Mistral, Gemini, and
7
+ OpenAI embedding options, dimensions, and API key behavior
8
8
  - [API reference](./API.md): exported functions, option shapes, and result data
9
9
  - [Integration examples](./INTEGRATIONS.md): Astro and Next.js server-side usage
10
10
  - [Indexing and operations](./INDEXING.md): content layout, rebuild scripts,
@@ -9,4 +9,6 @@ Common operational checks:
9
9
  - verify you called `createTable()` before indexing or searching
10
10
  - verify the table dimension matches the embedding dimension in your code
11
11
  - verify the same provider is used for indexing and querying
12
- - verify hosted providers have `GEMINI_API_KEY` or `OPENAI_API_KEY` available
12
+ - verify hosted providers have `CLOUDFLARE_ACCOUNT_ID` and
13
+ `CLOUDFLARE_API_TOKEN`, `MISTRAL_API_KEY`, `GEMINI_API_KEY`, or
14
+ `OPENAI_API_KEY` available as required by the selected provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libsql-search",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "Semantic search for static sites using libSQL/Turso with multi-provider embeddings",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.34.5",