libsql-search 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.esm.js CHANGED
@@ -3,6 +3,7 @@ import { join, extname, relative, dirname } from 'path';
3
3
  import matter from 'gray-matter';
4
4
 
5
5
  const OPENAI_DEFAULT_DIMENSIONS = 768;
6
+ const OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE = 32;
6
7
  const LOCAL_DIMENSIONS = 384;
7
8
  const DEFAULT_MAX_LENGTH = 8e3;
8
9
  const DEFAULT_TIMEOUT_MS = 3e4;
@@ -109,6 +110,7 @@ function resolveProviderName(provider) {
109
110
  case "openai":
110
111
  case "mistral":
111
112
  case "cloudflare":
113
+ case "openai-compatible":
112
114
  return provider ?? "local";
113
115
  default:
114
116
  throw new Error(`Unknown embedding provider: ${String(provider)}`);
@@ -133,6 +135,13 @@ function getOptionalTrimmedCredential(value) {
133
135
  const trimmed = value?.trim();
134
136
  return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
135
137
  }
138
+ function getRequiredTrimmedString(value, optionName) {
139
+ const trimmed = getOptionalTrimmedCredential(value);
140
+ if (!trimmed) {
141
+ throw new Error(`Invalid ${optionName}: expected a non-empty string`);
142
+ }
143
+ return trimmed;
144
+ }
136
145
  function hasOwnProperty(value, property) {
137
146
  return Object.prototype.hasOwnProperty.call(value, property);
138
147
  }
@@ -293,7 +302,27 @@ async function embedSequentially(provider, operation, texts, signal, embedOne) {
293
302
  }
294
303
  return results;
295
304
  }
296
- function createProviderMetadata(provider, dimensions) {
305
+ function normalizeOpenAICompatibleEmbeddingsUrl(baseUrl) {
306
+ let url;
307
+ try {
308
+ url = new URL(baseUrl);
309
+ } catch {
310
+ throw new Error("Invalid baseUrl: expected an absolute http or https URL");
311
+ }
312
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
313
+ throw new Error("Invalid baseUrl: expected an absolute http or https URL");
314
+ }
315
+ if (url.username || url.password) {
316
+ throw new Error("Invalid baseUrl: URL credentials are not allowed");
317
+ }
318
+ if (url.search || url.hash) {
319
+ throw new Error("Invalid baseUrl: query strings and fragments are not allowed");
320
+ }
321
+ const path = url.pathname.replace(/\/+$/, "");
322
+ url.pathname = path.endsWith("/embeddings") ? path : `${path}/embeddings`;
323
+ return url.toString();
324
+ }
325
+ function createProviderMetadata(provider, dimensions, model) {
297
326
  switch (provider) {
298
327
  case "local":
299
328
  return Object.freeze({
@@ -330,6 +359,13 @@ function createProviderMetadata(provider, dimensions) {
330
359
  dimensions: CLOUDFLARE_DIMENSIONS,
331
360
  batch: Object.freeze({ mode: "native" })
332
361
  });
362
+ case "openai-compatible":
363
+ return Object.freeze({
364
+ name: "openai-compatible",
365
+ model: getRequiredTrimmedString(model, "model"),
366
+ dimensions,
367
+ batch: Object.freeze({ mode: "native" })
368
+ });
333
369
  }
334
370
  }
335
371
  function getEffectiveDimensions(provider, dimensions) {
@@ -370,6 +406,12 @@ function getEffectiveDimensions(provider, dimensions) {
370
406
  }
371
407
  return CLOUDFLARE_DIMENSIONS;
372
408
  }
409
+ if (provider === "openai-compatible") {
410
+ if (dimensions === void 0) {
411
+ throw new Error("Invalid dimensions: expected a positive integer");
412
+ }
413
+ return getPositiveInteger(dimensions, "dimensions");
414
+ }
373
415
  return getPositiveInteger(dimensions ?? OPENAI_DEFAULT_DIMENSIONS, "dimensions");
374
416
  }
375
417
  function assertBatchSize(metadata, count) {
@@ -380,6 +422,13 @@ function assertBatchSize(metadata, count) {
380
422
  );
381
423
  }
382
424
  }
425
+ function chunkTexts(texts, batchSize) {
426
+ const chunks = [];
427
+ for (let index = 0; index < texts.length; index += batchSize) {
428
+ chunks.push(texts.slice(index, index + batchSize));
429
+ }
430
+ return chunks;
431
+ }
383
432
  function createEmbeddingBatchResult(metadata, intent, embeddings) {
384
433
  return Object.freeze({
385
434
  embeddings,
@@ -480,18 +529,23 @@ async function requestOpenAICompatibleEmbeddings(request) {
480
529
  if (request.encodingFormat !== void 0) {
481
530
  body.encoding_format = request.encodingFormat;
482
531
  }
532
+ const headers = {
533
+ "Content-Type": "application/json"
534
+ };
535
+ if (request.apiKey !== void 0) {
536
+ headers.Authorization = `Bearer ${request.apiKey}`;
537
+ }
483
538
  const response = await fetch(request.endpoint, {
484
539
  method: "POST",
485
- headers: {
486
- "Content-Type": "application/json",
487
- "Authorization": `Bearer ${request.apiKey}`
488
- },
540
+ headers,
489
541
  signal: request.signal,
490
- body: JSON.stringify(body)
542
+ body: JSON.stringify(body),
543
+ ...request.redirect ? { redirect: request.redirect } : {}
491
544
  });
492
545
  if (!response.ok) {
493
- const requestId = getSafeResponseHeader(response, "x-request-id");
494
- const context = requestId ? ` status ${response.status}, request ${requestId}` : ` status ${response.status}`;
546
+ const statusHeader = request.statusHeader ?? { name: "x-request-id", label: "request" };
547
+ const statusHeaderValue = getSafeResponseHeader(response, statusHeader.name);
548
+ const context = statusHeaderValue ? ` status ${response.status}, ${statusHeader.label} ${statusHeaderValue}` : ` status ${response.status}`;
495
549
  throw new Error(context);
496
550
  }
497
551
  const data = await response.json();
@@ -515,13 +569,13 @@ async function requestOpenAICompatibleEmbeddings(request) {
515
569
  return parsed;
516
570
  });
517
571
  }
518
- class OpenAIEmbeddingProvider {
572
+ class OpenAICompatibleEmbeddingProvider {
519
573
  metadata;
520
- #apiKey;
521
574
  #timeoutMs;
522
- constructor(metadata, apiKey, timeoutMs) {
575
+ #profile;
576
+ constructor(metadata, profile, timeoutMs) {
523
577
  this.metadata = metadata;
524
- this.#apiKey = apiKey;
578
+ this.#profile = profile;
525
579
  this.#timeoutMs = timeoutMs;
526
580
  }
527
581
  async embed(texts, options = {}) {
@@ -531,131 +585,44 @@ class OpenAIEmbeddingProvider {
531
585
  }
532
586
  assertBatchSize(this.metadata, texts.length);
533
587
  const items = await withTimeout(
534
- "openai",
535
- "API request",
536
- this.#timeoutMs,
537
- async (signal) => requestOpenAICompatibleEmbeddings({
538
- responseLabel: "OpenAI",
539
- endpoint: OPENAI_EMBEDDINGS_URL,
540
- apiKey: this.#apiKey,
541
- model: this.metadata.model,
542
- texts,
543
- signal,
544
- dimensions: this.metadata.dimensions
545
- }),
546
- options.signal,
547
- [this.#apiKey]
548
- );
549
- return createEmbeddingBatchResult(
550
- this.metadata,
551
- intent,
552
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
553
- );
554
- }
555
- }
556
- class MistralEmbeddingProvider {
557
- metadata;
558
- #apiKey;
559
- #timeoutMs;
560
- constructor(metadata, apiKey, timeoutMs) {
561
- this.metadata = metadata;
562
- this.#apiKey = apiKey;
563
- this.#timeoutMs = timeoutMs;
564
- }
565
- async embed(texts, options = {}) {
566
- const intent = resolveIntent(options.intent);
567
- if (texts.length === 0) {
568
- return createEmbeddingBatchResult(this.metadata, intent, []);
569
- }
570
- assertBatchSize(this.metadata, texts.length);
571
- const items = await withTimeout(
572
- "mistral",
573
- "API request",
574
- this.#timeoutMs,
575
- async (signal) => requestOpenAICompatibleEmbeddings({
576
- responseLabel: "Mistral",
577
- endpoint: MISTRAL_EMBEDDINGS_URL,
578
- apiKey: this.#apiKey,
579
- model: this.metadata.model,
580
- texts,
581
- signal,
582
- encodingFormat: "float",
583
- requireIndex: true
584
- }),
585
- options.signal,
586
- [this.#apiKey]
587
- );
588
- return createEmbeddingBatchResult(
589
- this.metadata,
590
- intent,
591
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
592
- );
593
- }
594
- }
595
- class CloudflareEmbeddingProvider {
596
- metadata;
597
- #accountId;
598
- #apiToken;
599
- #timeoutMs;
600
- constructor(metadata, accountId, apiToken, timeoutMs) {
601
- this.metadata = metadata;
602
- this.#accountId = accountId;
603
- this.#apiToken = apiToken;
604
- this.#timeoutMs = timeoutMs;
605
- }
606
- async embed(texts, options = {}) {
607
- const intent = resolveIntent(options.intent);
608
- if (texts.length === 0) {
609
- return createEmbeddingBatchResult(this.metadata, intent, []);
610
- }
611
- assertBatchSize(this.metadata, texts.length);
612
- const endpoint = createCloudflareEmbeddingsUrl(this.#accountId);
613
- const items = await withTimeout(
614
- "cloudflare",
588
+ this.#profile.provider,
615
589
  "API request",
616
590
  this.#timeoutMs,
617
591
  async (signal) => {
618
- const response = await fetch(endpoint, {
619
- method: "POST",
620
- headers: {
621
- "Content-Type": "application/json",
622
- "Authorization": `Bearer ${this.#apiToken}`
623
- },
624
- signal,
625
- body: JSON.stringify({
626
- model: this.metadata.model,
627
- input: texts
628
- })
629
- });
630
- if (!response.ok) {
631
- const cfRay = getSafeResponseHeader(response, "cf-ray");
632
- const context = cfRay ? ` status ${response.status}, cf-ray ${cfRay}` : ` status ${response.status}`;
633
- throw new Error(context);
634
- }
635
- const data = await response.json();
636
- if (!Array.isArray(data.data)) {
637
- throw new Error("Cloudflare response did not include a data array");
592
+ const batches = this.#profile.batchSize === void 0 ? [texts] : chunkTexts(texts, this.#profile.batchSize);
593
+ const results = [];
594
+ for (const batch of batches) {
595
+ throwIfAborted(this.#profile.provider, "API request", signal);
596
+ const batchItems = await requestOpenAICompatibleEmbeddings({
597
+ responseLabel: this.#profile.responseLabel,
598
+ endpoint: this.#profile.endpoint,
599
+ ...this.#profile.apiKey !== void 0 ? { apiKey: this.#profile.apiKey } : {},
600
+ model: this.#profile.model,
601
+ texts: batch,
602
+ signal,
603
+ ...this.#profile.includeDimensions ? { dimensions: this.#profile.dimensions } : {},
604
+ ...this.#profile.encodingFormat ? { encodingFormat: this.#profile.encodingFormat } : {},
605
+ ...this.#profile.requireIndex !== void 0 ? { requireIndex: this.#profile.requireIndex } : {},
606
+ ...this.#profile.redirect ? { redirect: this.#profile.redirect } : {},
607
+ ...this.#profile.statusHeader ? { statusHeader: this.#profile.statusHeader } : {}
608
+ });
609
+ results.push(...validateEmbeddingBatch(
610
+ batchItems,
611
+ batch.length,
612
+ this.metadata.dimensions,
613
+ this.#profile.provider
614
+ ));
615
+ throwIfAborted(this.#profile.provider, "API request", signal);
638
616
  }
639
- return data.data.map((item) => {
640
- const typed = item;
641
- const embedding = typed.embedding;
642
- if (!Array.isArray(embedding)) {
643
- throw new Error("Cloudflare response item did not include an embedding array");
644
- }
645
- const parsed = { embedding };
646
- if (hasOwnProperty(typed, "index")) {
647
- parsed.index = typed.index;
648
- }
649
- return parsed;
650
- });
617
+ return results;
651
618
  },
652
619
  options.signal,
653
- [this.#apiToken, this.#accountId, encodeURIComponent(this.#accountId)]
620
+ this.#profile.exactSecrets ?? []
654
621
  );
655
622
  return createEmbeddingBatchResult(
656
623
  this.metadata,
657
624
  intent,
658
- validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "cloudflare")
625
+ validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, this.#profile.provider)
659
626
  );
660
627
  }
661
628
  }
@@ -680,7 +647,16 @@ function createEmbeddingProvider(options = {}) {
680
647
  if (!key) {
681
648
  throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
682
649
  }
683
- return new OpenAIEmbeddingProvider(metadata, key, timeoutMs);
650
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
651
+ provider: "openai",
652
+ responseLabel: "OpenAI",
653
+ endpoint: OPENAI_EMBEDDINGS_URL,
654
+ apiKey: key,
655
+ model: metadata.model,
656
+ dimensions: metadata.dimensions,
657
+ includeDimensions: true,
658
+ exactSecrets: [key]
659
+ }, timeoutMs);
684
660
  }
685
661
  case "mistral": {
686
662
  const key = getOptionalTrimmedCredential(
@@ -689,7 +665,17 @@ function createEmbeddingProvider(options = {}) {
689
665
  if (!key) {
690
666
  throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
691
667
  }
692
- return new MistralEmbeddingProvider(metadata, key, timeoutMs);
668
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
669
+ provider: "mistral",
670
+ responseLabel: "Mistral",
671
+ endpoint: MISTRAL_EMBEDDINGS_URL,
672
+ apiKey: key,
673
+ model: metadata.model,
674
+ dimensions: metadata.dimensions,
675
+ encodingFormat: "float",
676
+ requireIndex: true,
677
+ exactSecrets: [key]
678
+ }, timeoutMs);
693
679
  }
694
680
  case "cloudflare": {
695
681
  const accountId = getOptionalTrimmedCredential(
@@ -704,13 +690,56 @@ function createEmbeddingProvider(options = {}) {
704
690
  if (!apiToken) {
705
691
  throw new Error("CLOUDFLARE_API_TOKEN is required for Cloudflare embeddings");
706
692
  }
707
- return new CloudflareEmbeddingProvider(metadata, accountId, apiToken, timeoutMs);
693
+ const endpoint = createCloudflareEmbeddingsUrl(accountId);
694
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
695
+ provider: "cloudflare",
696
+ responseLabel: "Cloudflare",
697
+ endpoint,
698
+ apiKey: apiToken,
699
+ model: metadata.model,
700
+ dimensions: metadata.dimensions,
701
+ statusHeader: { name: "cf-ray", label: "cf-ray" },
702
+ exactSecrets: [apiToken, accountId, encodeURIComponent(accountId), endpoint]
703
+ }, timeoutMs);
704
+ }
705
+ case "openai-compatible": {
706
+ const baseUrl = getRequiredTrimmedString(options.baseUrl, "baseUrl");
707
+ const endpoint = normalizeOpenAICompatibleEmbeddingsUrl(baseUrl);
708
+ const apiKey = getOptionalTrimmedCredential(options.apiKey);
709
+ const batchSize = getPositiveInteger(
710
+ options.batchSize ?? OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE,
711
+ "batchSize"
712
+ );
713
+ return new OpenAICompatibleEmbeddingProvider(metadata, {
714
+ provider: "openai-compatible",
715
+ responseLabel: "OpenAI-compatible",
716
+ endpoint,
717
+ ...apiKey ? { apiKey } : {},
718
+ model: metadata.model,
719
+ dimensions: metadata.dimensions,
720
+ includeDimensions: true,
721
+ encodingFormat: "float",
722
+ requireIndex: true,
723
+ redirect: "error",
724
+ batchSize,
725
+ exactSecrets: [
726
+ ...apiKey ? [apiKey] : [],
727
+ baseUrl,
728
+ endpoint
729
+ ]
730
+ }, timeoutMs);
708
731
  }
709
732
  }
710
733
  }
711
734
  function getEmbeddingProviderMetadata(options = {}) {
712
735
  const provider = resolveProviderName(options.provider);
713
- return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions));
736
+ if (provider === "openai-compatible") {
737
+ normalizeOpenAICompatibleEmbeddingsUrl(getRequiredTrimmedString(options.baseUrl, "baseUrl"));
738
+ if (options.batchSize !== void 0) {
739
+ getPositiveInteger(options.batchSize, "batchSize");
740
+ }
741
+ }
742
+ return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions), options.model);
714
743
  }
715
744
  async function generateEmbeddings(texts, options = {}) {
716
745
  const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");