libsql-search 0.6.0 → 0.7.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 +2 -1
- package/dist/index.cjs +160 -131
- package/dist/index.d.ts +6 -2
- package/dist/index.esm.js +160 -131
- package/docs/API.md +24 -2
- package/docs/INTEGRATIONS.md +13 -1
- package/docs/PROVIDERS.md +102 -6
- package/docs/README.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,8 @@ Use it when you want:
|
|
|
23
23
|
- Embedding providers: local Hugging Face `Xenova/all-MiniLM-L6-v2`,
|
|
24
24
|
Cloudflare Workers AI `@cf/baai/bge-m3`, Mistral `mistral-embed`, Google Gemini
|
|
25
25
|
`gemini-embedding-2`, and OpenAI `text-embedding-3-small` /
|
|
26
|
-
`text-embedding-3-large
|
|
26
|
+
`text-embedding-3-large`; self-hosted OpenAI-compatible endpoints are also
|
|
27
|
+
available for TEI and similar trusted deployments
|
|
27
28
|
- npm distribution plus JSR publishing
|
|
28
29
|
|
|
29
30
|
## Install
|
package/dist/index.cjs
CHANGED
|
@@ -5,6 +5,7 @@ var path = require('path');
|
|
|
5
5
|
var matter = require('gray-matter');
|
|
6
6
|
|
|
7
7
|
const OPENAI_DEFAULT_DIMENSIONS = 768;
|
|
8
|
+
const OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE = 32;
|
|
8
9
|
const LOCAL_DIMENSIONS = 384;
|
|
9
10
|
const DEFAULT_MAX_LENGTH = 8e3;
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -111,6 +112,7 @@ function resolveProviderName(provider) {
|
|
|
111
112
|
case "openai":
|
|
112
113
|
case "mistral":
|
|
113
114
|
case "cloudflare":
|
|
115
|
+
case "openai-compatible":
|
|
114
116
|
return provider ?? "local";
|
|
115
117
|
default:
|
|
116
118
|
throw new Error(`Unknown embedding provider: ${String(provider)}`);
|
|
@@ -135,6 +137,13 @@ function getOptionalTrimmedCredential(value) {
|
|
|
135
137
|
const trimmed = value?.trim();
|
|
136
138
|
return trimmed === void 0 || trimmed.length === 0 ? void 0 : trimmed;
|
|
137
139
|
}
|
|
140
|
+
function getRequiredTrimmedString(value, optionName) {
|
|
141
|
+
const trimmed = getOptionalTrimmedCredential(value);
|
|
142
|
+
if (!trimmed) {
|
|
143
|
+
throw new Error(`Invalid ${optionName}: expected a non-empty string`);
|
|
144
|
+
}
|
|
145
|
+
return trimmed;
|
|
146
|
+
}
|
|
138
147
|
function hasOwnProperty(value, property) {
|
|
139
148
|
return Object.prototype.hasOwnProperty.call(value, property);
|
|
140
149
|
}
|
|
@@ -295,7 +304,27 @@ async function embedSequentially(provider, operation, texts, signal, embedOne) {
|
|
|
295
304
|
}
|
|
296
305
|
return results;
|
|
297
306
|
}
|
|
298
|
-
function
|
|
307
|
+
function normalizeOpenAICompatibleEmbeddingsUrl(baseUrl) {
|
|
308
|
+
let url;
|
|
309
|
+
try {
|
|
310
|
+
url = new URL(baseUrl);
|
|
311
|
+
} catch {
|
|
312
|
+
throw new Error("Invalid baseUrl: expected an absolute http or https URL");
|
|
313
|
+
}
|
|
314
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
315
|
+
throw new Error("Invalid baseUrl: expected an absolute http or https URL");
|
|
316
|
+
}
|
|
317
|
+
if (url.username || url.password) {
|
|
318
|
+
throw new Error("Invalid baseUrl: URL credentials are not allowed");
|
|
319
|
+
}
|
|
320
|
+
if (url.search || url.hash) {
|
|
321
|
+
throw new Error("Invalid baseUrl: query strings and fragments are not allowed");
|
|
322
|
+
}
|
|
323
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
324
|
+
url.pathname = path.endsWith("/embeddings") ? path : `${path}/embeddings`;
|
|
325
|
+
return url.toString();
|
|
326
|
+
}
|
|
327
|
+
function createProviderMetadata(provider, dimensions, model) {
|
|
299
328
|
switch (provider) {
|
|
300
329
|
case "local":
|
|
301
330
|
return Object.freeze({
|
|
@@ -332,6 +361,13 @@ function createProviderMetadata(provider, dimensions) {
|
|
|
332
361
|
dimensions: CLOUDFLARE_DIMENSIONS,
|
|
333
362
|
batch: Object.freeze({ mode: "native" })
|
|
334
363
|
});
|
|
364
|
+
case "openai-compatible":
|
|
365
|
+
return Object.freeze({
|
|
366
|
+
name: "openai-compatible",
|
|
367
|
+
model: getRequiredTrimmedString(model, "model"),
|
|
368
|
+
dimensions,
|
|
369
|
+
batch: Object.freeze({ mode: "native" })
|
|
370
|
+
});
|
|
335
371
|
}
|
|
336
372
|
}
|
|
337
373
|
function getEffectiveDimensions(provider, dimensions) {
|
|
@@ -372,6 +408,12 @@ function getEffectiveDimensions(provider, dimensions) {
|
|
|
372
408
|
}
|
|
373
409
|
return CLOUDFLARE_DIMENSIONS;
|
|
374
410
|
}
|
|
411
|
+
if (provider === "openai-compatible") {
|
|
412
|
+
if (dimensions === void 0) {
|
|
413
|
+
throw new Error("Invalid dimensions: expected a positive integer");
|
|
414
|
+
}
|
|
415
|
+
return getPositiveInteger(dimensions, "dimensions");
|
|
416
|
+
}
|
|
375
417
|
return getPositiveInteger(dimensions ?? OPENAI_DEFAULT_DIMENSIONS, "dimensions");
|
|
376
418
|
}
|
|
377
419
|
function assertBatchSize(metadata, count) {
|
|
@@ -382,6 +424,13 @@ function assertBatchSize(metadata, count) {
|
|
|
382
424
|
);
|
|
383
425
|
}
|
|
384
426
|
}
|
|
427
|
+
function chunkTexts(texts, batchSize) {
|
|
428
|
+
const chunks = [];
|
|
429
|
+
for (let index = 0; index < texts.length; index += batchSize) {
|
|
430
|
+
chunks.push(texts.slice(index, index + batchSize));
|
|
431
|
+
}
|
|
432
|
+
return chunks;
|
|
433
|
+
}
|
|
385
434
|
function createEmbeddingBatchResult(metadata, intent, embeddings) {
|
|
386
435
|
return Object.freeze({
|
|
387
436
|
embeddings,
|
|
@@ -482,18 +531,23 @@ async function requestOpenAICompatibleEmbeddings(request) {
|
|
|
482
531
|
if (request.encodingFormat !== void 0) {
|
|
483
532
|
body.encoding_format = request.encodingFormat;
|
|
484
533
|
}
|
|
534
|
+
const headers = {
|
|
535
|
+
"Content-Type": "application/json"
|
|
536
|
+
};
|
|
537
|
+
if (request.apiKey !== void 0) {
|
|
538
|
+
headers.Authorization = `Bearer ${request.apiKey}`;
|
|
539
|
+
}
|
|
485
540
|
const response = await fetch(request.endpoint, {
|
|
486
541
|
method: "POST",
|
|
487
|
-
headers
|
|
488
|
-
"Content-Type": "application/json",
|
|
489
|
-
"Authorization": `Bearer ${request.apiKey}`
|
|
490
|
-
},
|
|
542
|
+
headers,
|
|
491
543
|
signal: request.signal,
|
|
492
|
-
body: JSON.stringify(body)
|
|
544
|
+
body: JSON.stringify(body),
|
|
545
|
+
...request.redirect ? { redirect: request.redirect } : {}
|
|
493
546
|
});
|
|
494
547
|
if (!response.ok) {
|
|
495
|
-
const
|
|
496
|
-
const
|
|
548
|
+
const statusHeader = request.statusHeader ?? { name: "x-request-id", label: "request" };
|
|
549
|
+
const statusHeaderValue = getSafeResponseHeader(response, statusHeader.name);
|
|
550
|
+
const context = statusHeaderValue ? ` status ${response.status}, ${statusHeader.label} ${statusHeaderValue}` : ` status ${response.status}`;
|
|
497
551
|
throw new Error(context);
|
|
498
552
|
}
|
|
499
553
|
const data = await response.json();
|
|
@@ -517,13 +571,13 @@ async function requestOpenAICompatibleEmbeddings(request) {
|
|
|
517
571
|
return parsed;
|
|
518
572
|
});
|
|
519
573
|
}
|
|
520
|
-
class
|
|
574
|
+
class OpenAICompatibleEmbeddingProvider {
|
|
521
575
|
metadata;
|
|
522
|
-
#apiKey;
|
|
523
576
|
#timeoutMs;
|
|
524
|
-
|
|
577
|
+
#profile;
|
|
578
|
+
constructor(metadata, profile, timeoutMs) {
|
|
525
579
|
this.metadata = metadata;
|
|
526
|
-
this.#
|
|
580
|
+
this.#profile = profile;
|
|
527
581
|
this.#timeoutMs = timeoutMs;
|
|
528
582
|
}
|
|
529
583
|
async embed(texts, options = {}) {
|
|
@@ -533,131 +587,44 @@ class OpenAIEmbeddingProvider {
|
|
|
533
587
|
}
|
|
534
588
|
assertBatchSize(this.metadata, texts.length);
|
|
535
589
|
const items = await withTimeout(
|
|
536
|
-
|
|
537
|
-
"API request",
|
|
538
|
-
this.#timeoutMs,
|
|
539
|
-
async (signal) => requestOpenAICompatibleEmbeddings({
|
|
540
|
-
responseLabel: "OpenAI",
|
|
541
|
-
endpoint: OPENAI_EMBEDDINGS_URL,
|
|
542
|
-
apiKey: this.#apiKey,
|
|
543
|
-
model: this.metadata.model,
|
|
544
|
-
texts,
|
|
545
|
-
signal,
|
|
546
|
-
dimensions: this.metadata.dimensions
|
|
547
|
-
}),
|
|
548
|
-
options.signal,
|
|
549
|
-
[this.#apiKey]
|
|
550
|
-
);
|
|
551
|
-
return createEmbeddingBatchResult(
|
|
552
|
-
this.metadata,
|
|
553
|
-
intent,
|
|
554
|
-
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "openai")
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
class MistralEmbeddingProvider {
|
|
559
|
-
metadata;
|
|
560
|
-
#apiKey;
|
|
561
|
-
#timeoutMs;
|
|
562
|
-
constructor(metadata, apiKey, timeoutMs) {
|
|
563
|
-
this.metadata = metadata;
|
|
564
|
-
this.#apiKey = apiKey;
|
|
565
|
-
this.#timeoutMs = timeoutMs;
|
|
566
|
-
}
|
|
567
|
-
async embed(texts, options = {}) {
|
|
568
|
-
const intent = resolveIntent(options.intent);
|
|
569
|
-
if (texts.length === 0) {
|
|
570
|
-
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
571
|
-
}
|
|
572
|
-
assertBatchSize(this.metadata, texts.length);
|
|
573
|
-
const items = await withTimeout(
|
|
574
|
-
"mistral",
|
|
575
|
-
"API request",
|
|
576
|
-
this.#timeoutMs,
|
|
577
|
-
async (signal) => requestOpenAICompatibleEmbeddings({
|
|
578
|
-
responseLabel: "Mistral",
|
|
579
|
-
endpoint: MISTRAL_EMBEDDINGS_URL,
|
|
580
|
-
apiKey: this.#apiKey,
|
|
581
|
-
model: this.metadata.model,
|
|
582
|
-
texts,
|
|
583
|
-
signal,
|
|
584
|
-
encodingFormat: "float",
|
|
585
|
-
requireIndex: true
|
|
586
|
-
}),
|
|
587
|
-
options.signal,
|
|
588
|
-
[this.#apiKey]
|
|
589
|
-
);
|
|
590
|
-
return createEmbeddingBatchResult(
|
|
591
|
-
this.metadata,
|
|
592
|
-
intent,
|
|
593
|
-
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, "mistral")
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
class CloudflareEmbeddingProvider {
|
|
598
|
-
metadata;
|
|
599
|
-
#accountId;
|
|
600
|
-
#apiToken;
|
|
601
|
-
#timeoutMs;
|
|
602
|
-
constructor(metadata, accountId, apiToken, timeoutMs) {
|
|
603
|
-
this.metadata = metadata;
|
|
604
|
-
this.#accountId = accountId;
|
|
605
|
-
this.#apiToken = apiToken;
|
|
606
|
-
this.#timeoutMs = timeoutMs;
|
|
607
|
-
}
|
|
608
|
-
async embed(texts, options = {}) {
|
|
609
|
-
const intent = resolveIntent(options.intent);
|
|
610
|
-
if (texts.length === 0) {
|
|
611
|
-
return createEmbeddingBatchResult(this.metadata, intent, []);
|
|
612
|
-
}
|
|
613
|
-
assertBatchSize(this.metadata, texts.length);
|
|
614
|
-
const endpoint = createCloudflareEmbeddingsUrl(this.#accountId);
|
|
615
|
-
const items = await withTimeout(
|
|
616
|
-
"cloudflare",
|
|
590
|
+
this.#profile.provider,
|
|
617
591
|
"API request",
|
|
618
592
|
this.#timeoutMs,
|
|
619
593
|
async (signal) => {
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
model: this.
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
594
|
+
const batches = this.#profile.batchSize === void 0 ? [texts] : chunkTexts(texts, this.#profile.batchSize);
|
|
595
|
+
const results = [];
|
|
596
|
+
for (const batch of batches) {
|
|
597
|
+
throwIfAborted(this.#profile.provider, "API request", signal);
|
|
598
|
+
const batchItems = await requestOpenAICompatibleEmbeddings({
|
|
599
|
+
responseLabel: this.#profile.responseLabel,
|
|
600
|
+
endpoint: this.#profile.endpoint,
|
|
601
|
+
...this.#profile.apiKey !== void 0 ? { apiKey: this.#profile.apiKey } : {},
|
|
602
|
+
model: this.#profile.model,
|
|
603
|
+
texts: batch,
|
|
604
|
+
signal,
|
|
605
|
+
...this.#profile.includeDimensions ? { dimensions: this.#profile.dimensions } : {},
|
|
606
|
+
...this.#profile.encodingFormat ? { encodingFormat: this.#profile.encodingFormat } : {},
|
|
607
|
+
...this.#profile.requireIndex !== void 0 ? { requireIndex: this.#profile.requireIndex } : {},
|
|
608
|
+
...this.#profile.redirect ? { redirect: this.#profile.redirect } : {},
|
|
609
|
+
...this.#profile.statusHeader ? { statusHeader: this.#profile.statusHeader } : {}
|
|
610
|
+
});
|
|
611
|
+
results.push(...validateEmbeddingBatch(
|
|
612
|
+
batchItems,
|
|
613
|
+
batch.length,
|
|
614
|
+
this.metadata.dimensions,
|
|
615
|
+
this.#profile.provider
|
|
616
|
+
));
|
|
617
|
+
throwIfAborted(this.#profile.provider, "API request", signal);
|
|
640
618
|
}
|
|
641
|
-
return
|
|
642
|
-
const typed = item;
|
|
643
|
-
const embedding = typed.embedding;
|
|
644
|
-
if (!Array.isArray(embedding)) {
|
|
645
|
-
throw new Error("Cloudflare response item did not include an embedding array");
|
|
646
|
-
}
|
|
647
|
-
const parsed = { embedding };
|
|
648
|
-
if (hasOwnProperty(typed, "index")) {
|
|
649
|
-
parsed.index = typed.index;
|
|
650
|
-
}
|
|
651
|
-
return parsed;
|
|
652
|
-
});
|
|
619
|
+
return results;
|
|
653
620
|
},
|
|
654
621
|
options.signal,
|
|
655
|
-
|
|
622
|
+
this.#profile.exactSecrets ?? []
|
|
656
623
|
);
|
|
657
624
|
return createEmbeddingBatchResult(
|
|
658
625
|
this.metadata,
|
|
659
626
|
intent,
|
|
660
|
-
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions,
|
|
627
|
+
validateEmbeddingBatch(items, texts.length, this.metadata.dimensions, this.#profile.provider)
|
|
661
628
|
);
|
|
662
629
|
}
|
|
663
630
|
}
|
|
@@ -682,7 +649,16 @@ function createEmbeddingProvider(options = {}) {
|
|
|
682
649
|
if (!key) {
|
|
683
650
|
throw new Error("OPENAI_API_KEY is required for OpenAI embeddings");
|
|
684
651
|
}
|
|
685
|
-
return new
|
|
652
|
+
return new OpenAICompatibleEmbeddingProvider(metadata, {
|
|
653
|
+
provider: "openai",
|
|
654
|
+
responseLabel: "OpenAI",
|
|
655
|
+
endpoint: OPENAI_EMBEDDINGS_URL,
|
|
656
|
+
apiKey: key,
|
|
657
|
+
model: metadata.model,
|
|
658
|
+
dimensions: metadata.dimensions,
|
|
659
|
+
includeDimensions: true,
|
|
660
|
+
exactSecrets: [key]
|
|
661
|
+
}, timeoutMs);
|
|
686
662
|
}
|
|
687
663
|
case "mistral": {
|
|
688
664
|
const key = getOptionalTrimmedCredential(
|
|
@@ -691,7 +667,17 @@ function createEmbeddingProvider(options = {}) {
|
|
|
691
667
|
if (!key) {
|
|
692
668
|
throw new Error("MISTRAL_API_KEY is required for Mistral embeddings");
|
|
693
669
|
}
|
|
694
|
-
return new
|
|
670
|
+
return new OpenAICompatibleEmbeddingProvider(metadata, {
|
|
671
|
+
provider: "mistral",
|
|
672
|
+
responseLabel: "Mistral",
|
|
673
|
+
endpoint: MISTRAL_EMBEDDINGS_URL,
|
|
674
|
+
apiKey: key,
|
|
675
|
+
model: metadata.model,
|
|
676
|
+
dimensions: metadata.dimensions,
|
|
677
|
+
encodingFormat: "float",
|
|
678
|
+
requireIndex: true,
|
|
679
|
+
exactSecrets: [key]
|
|
680
|
+
}, timeoutMs);
|
|
695
681
|
}
|
|
696
682
|
case "cloudflare": {
|
|
697
683
|
const accountId = getOptionalTrimmedCredential(
|
|
@@ -706,13 +692,56 @@ function createEmbeddingProvider(options = {}) {
|
|
|
706
692
|
if (!apiToken) {
|
|
707
693
|
throw new Error("CLOUDFLARE_API_TOKEN is required for Cloudflare embeddings");
|
|
708
694
|
}
|
|
709
|
-
|
|
695
|
+
const endpoint = createCloudflareEmbeddingsUrl(accountId);
|
|
696
|
+
return new OpenAICompatibleEmbeddingProvider(metadata, {
|
|
697
|
+
provider: "cloudflare",
|
|
698
|
+
responseLabel: "Cloudflare",
|
|
699
|
+
endpoint,
|
|
700
|
+
apiKey: apiToken,
|
|
701
|
+
model: metadata.model,
|
|
702
|
+
dimensions: metadata.dimensions,
|
|
703
|
+
statusHeader: { name: "cf-ray", label: "cf-ray" },
|
|
704
|
+
exactSecrets: [apiToken, accountId, encodeURIComponent(accountId), endpoint]
|
|
705
|
+
}, timeoutMs);
|
|
706
|
+
}
|
|
707
|
+
case "openai-compatible": {
|
|
708
|
+
const baseUrl = getRequiredTrimmedString(options.baseUrl, "baseUrl");
|
|
709
|
+
const endpoint = normalizeOpenAICompatibleEmbeddingsUrl(baseUrl);
|
|
710
|
+
const apiKey = getOptionalTrimmedCredential(options.apiKey);
|
|
711
|
+
const batchSize = getPositiveInteger(
|
|
712
|
+
options.batchSize ?? OPENAI_COMPATIBLE_DEFAULT_BATCH_SIZE,
|
|
713
|
+
"batchSize"
|
|
714
|
+
);
|
|
715
|
+
return new OpenAICompatibleEmbeddingProvider(metadata, {
|
|
716
|
+
provider: "openai-compatible",
|
|
717
|
+
responseLabel: "OpenAI-compatible",
|
|
718
|
+
endpoint,
|
|
719
|
+
...apiKey ? { apiKey } : {},
|
|
720
|
+
model: metadata.model,
|
|
721
|
+
dimensions: metadata.dimensions,
|
|
722
|
+
includeDimensions: true,
|
|
723
|
+
encodingFormat: "float",
|
|
724
|
+
requireIndex: true,
|
|
725
|
+
redirect: "error",
|
|
726
|
+
batchSize,
|
|
727
|
+
exactSecrets: [
|
|
728
|
+
...apiKey ? [apiKey] : [],
|
|
729
|
+
baseUrl,
|
|
730
|
+
endpoint
|
|
731
|
+
]
|
|
732
|
+
}, timeoutMs);
|
|
710
733
|
}
|
|
711
734
|
}
|
|
712
735
|
}
|
|
713
736
|
function getEmbeddingProviderMetadata(options = {}) {
|
|
714
737
|
const provider = resolveProviderName(options.provider);
|
|
715
|
-
|
|
738
|
+
if (provider === "openai-compatible") {
|
|
739
|
+
normalizeOpenAICompatibleEmbeddingsUrl(getRequiredTrimmedString(options.baseUrl, "baseUrl"));
|
|
740
|
+
if (options.batchSize !== void 0) {
|
|
741
|
+
getPositiveInteger(options.batchSize, "batchSize");
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return createProviderMetadata(provider, getEffectiveDimensions(provider, options.dimensions), options.model);
|
|
716
745
|
}
|
|
717
746
|
async function generateEmbeddings(texts, options = {}) {
|
|
718
747
|
const maxLength = getPositiveInteger(options.maxLength ?? DEFAULT_MAX_LENGTH, "maxLength");
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { Client } from '@libsql/client';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Multi-provider embedding generation
|
|
5
|
-
* Supports local Hugging Face Transformers, Gemini, OpenAI, Mistral,
|
|
5
|
+
* Supports local Hugging Face Transformers, Gemini, OpenAI, Mistral,
|
|
6
|
+
* Cloudflare Workers AI, and custom OpenAI-compatible endpoints
|
|
6
7
|
*/
|
|
7
|
-
type EmbeddingProvider = 'local' | 'gemini' | 'openai' | 'mistral' | 'cloudflare';
|
|
8
|
+
type EmbeddingProvider = 'local' | 'gemini' | 'openai' | 'mistral' | 'cloudflare' | 'openai-compatible';
|
|
8
9
|
type EmbeddingIntent = 'document' | 'query';
|
|
9
10
|
type EmbeddingBatchMode = 'native' | 'sequential';
|
|
10
11
|
interface EmbeddingBatchBehavior {
|
|
@@ -37,6 +38,9 @@ interface EmbeddingOptions {
|
|
|
37
38
|
apiKey?: string;
|
|
38
39
|
accountId?: string;
|
|
39
40
|
apiToken?: string;
|
|
41
|
+
baseUrl?: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
batchSize?: number;
|
|
40
44
|
dimensions?: number;
|
|
41
45
|
maxLength?: number;
|
|
42
46
|
intent?: EmbeddingIntent;
|
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
|
|
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
|
|
494
|
-
const
|
|
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
|
|
572
|
+
class OpenAICompatibleEmbeddingProvider {
|
|
519
573
|
metadata;
|
|
520
|
-
#apiKey;
|
|
521
574
|
#timeoutMs;
|
|
522
|
-
|
|
575
|
+
#profile;
|
|
576
|
+
constructor(metadata, profile, timeoutMs) {
|
|
523
577
|
this.metadata = metadata;
|
|
524
|
-
this.#
|
|
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
|
-
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
model: this.
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/docs/API.md
CHANGED
|
@@ -246,7 +246,13 @@ Provider clients return:
|
|
|
246
246
|
```ts
|
|
247
247
|
interface EmbeddingBatchResult {
|
|
248
248
|
embeddings: number[][];
|
|
249
|
-
provider:
|
|
249
|
+
provider:
|
|
250
|
+
| "local"
|
|
251
|
+
| "cloudflare"
|
|
252
|
+
| "mistral"
|
|
253
|
+
| "gemini"
|
|
254
|
+
| "openai"
|
|
255
|
+
| "openai-compatible";
|
|
250
256
|
model: string;
|
|
251
257
|
dimensions: number;
|
|
252
258
|
intent: "document" | "query";
|
|
@@ -262,10 +268,19 @@ cardinality, dimensions, finite numeric values, and indexed batch ordering.
|
|
|
262
268
|
|
|
263
269
|
```ts
|
|
264
270
|
interface EmbeddingOptions {
|
|
265
|
-
provider?:
|
|
271
|
+
provider?:
|
|
272
|
+
| "local"
|
|
273
|
+
| "cloudflare"
|
|
274
|
+
| "mistral"
|
|
275
|
+
| "gemini"
|
|
276
|
+
| "openai"
|
|
277
|
+
| "openai-compatible";
|
|
266
278
|
apiKey?: string;
|
|
267
279
|
accountId?: string;
|
|
268
280
|
apiToken?: string;
|
|
281
|
+
baseUrl?: string;
|
|
282
|
+
model?: string;
|
|
283
|
+
batchSize?: number;
|
|
269
284
|
dimensions?: number;
|
|
270
285
|
maxLength?: number;
|
|
271
286
|
intent?: "document" | "query";
|
|
@@ -279,6 +294,13 @@ interface EmbeddingOptions {
|
|
|
279
294
|
Cloudflare uses `accountId` and `apiToken`, which fall back to
|
|
280
295
|
`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`.
|
|
281
296
|
|
|
297
|
+
For `provider: "openai-compatible"`, callers must pass `baseUrl`, `model`, and
|
|
298
|
+
`dimensions`. `apiKey` is optional and is used only when explicitly provided;
|
|
299
|
+
the provider never reads `OPENAI_API_KEY`. `batchSize` controls the maximum
|
|
300
|
+
items per outbound request and defaults to `32`. The endpoint is treated as
|
|
301
|
+
trusted server-side configuration and should not be derived from untrusted
|
|
302
|
+
request input.
|
|
303
|
+
|
|
282
304
|
### `padEmbedding(embedding, targetDimensions)`
|
|
283
305
|
|
|
284
306
|
Pads or truncates an embedding array to the requested length.
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -132,6 +132,7 @@ const embeddingProvider =
|
|
|
132
132
|
| "mistral"
|
|
133
133
|
| "gemini"
|
|
134
134
|
| "openai"
|
|
135
|
+
| "openai-compatible"
|
|
135
136
|
| undefined;
|
|
136
137
|
|
|
137
138
|
const dimensionsByProvider = {
|
|
@@ -142,7 +143,10 @@ const dimensionsByProvider = {
|
|
|
142
143
|
openai: 1536,
|
|
143
144
|
} as const;
|
|
144
145
|
|
|
145
|
-
const embeddingDimensions =
|
|
146
|
+
const embeddingDimensions =
|
|
147
|
+
embeddingProvider === "openai-compatible"
|
|
148
|
+
? Number(process.env.EMBEDDING_DIMENSIONS)
|
|
149
|
+
: dimensionsByProvider[embeddingProvider ?? "local"];
|
|
146
150
|
|
|
147
151
|
await createTable(client, "articles", embeddingDimensions);
|
|
148
152
|
|
|
@@ -151,6 +155,8 @@ await indexContent({
|
|
|
151
155
|
contentPath: "./content",
|
|
152
156
|
embeddingOptions: {
|
|
153
157
|
provider: embeddingProvider,
|
|
158
|
+
baseUrl: process.env.EMBEDDING_BASE_URL,
|
|
159
|
+
model: process.env.EMBEDDING_MODEL,
|
|
154
160
|
dimensions: embeddingDimensions,
|
|
155
161
|
},
|
|
156
162
|
});
|
|
@@ -158,3 +164,9 @@ await indexContent({
|
|
|
158
164
|
|
|
159
165
|
Pair this with your framework build command so indexed content and deployed code
|
|
160
166
|
stay in sync.
|
|
167
|
+
|
|
168
|
+
When you use `openai-compatible`, keep `EMBEDDING_BASE_URL` as trusted
|
|
169
|
+
server-side configuration, not user request input. Recreate the vector table or
|
|
170
|
+
rebuild into a separate table whenever the provider, endpoint, model, or
|
|
171
|
+
dimension count changes; stored vectors and query vectors must come from the
|
|
172
|
+
same embedding space.
|
package/docs/PROVIDERS.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# Embedding Providers
|
|
2
2
|
|
|
3
|
-
`libsql-search` currently supports
|
|
3
|
+
`libsql-search` currently supports six embedding providers:
|
|
4
4
|
|
|
5
5
|
- `local`
|
|
6
6
|
- `cloudflare`
|
|
7
7
|
- `mistral`
|
|
8
8
|
- `gemini`
|
|
9
9
|
- `openai`
|
|
10
|
+
- `openai-compatible`
|
|
10
11
|
|
|
11
12
|
Use the same provider and dimensions for both indexing and querying. A mismatch
|
|
12
13
|
between stored vectors and query vectors will break search quality or fail at
|
|
@@ -16,10 +17,19 @@ query time.
|
|
|
16
17
|
|
|
17
18
|
```ts
|
|
18
19
|
interface EmbeddingOptions {
|
|
19
|
-
provider?:
|
|
20
|
+
provider?:
|
|
21
|
+
| "local"
|
|
22
|
+
| "cloudflare"
|
|
23
|
+
| "mistral"
|
|
24
|
+
| "gemini"
|
|
25
|
+
| "openai"
|
|
26
|
+
| "openai-compatible";
|
|
20
27
|
apiKey?: string;
|
|
21
28
|
accountId?: string;
|
|
22
29
|
apiToken?: string;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
model?: string;
|
|
32
|
+
batchSize?: number;
|
|
23
33
|
dimensions?: number;
|
|
24
34
|
maxLength?: number;
|
|
25
35
|
intent?: "document" | "query";
|
|
@@ -37,9 +47,12 @@ interface EmbeddingOptions {
|
|
|
37
47
|
`"document"` and search defaults to `"query"` unless explicitly set
|
|
38
48
|
- `timeoutMs` defaults to `30000`
|
|
39
49
|
- `apiKey` is used by Mistral, Gemini, and OpenAI and falls back to
|
|
40
|
-
`MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY
|
|
50
|
+
`MISTRAL_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY`. For
|
|
51
|
+
`openai-compatible`, `apiKey` is optional and never falls back to
|
|
52
|
+
`OPENAI_API_KEY`.
|
|
41
53
|
- `accountId` and `apiToken` are used by Cloudflare and fall back to
|
|
42
54
|
`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`
|
|
55
|
+
- `baseUrl`, `model`, and `batchSize` are used by `openai-compatible`
|
|
43
56
|
|
|
44
57
|
## Provider Contract
|
|
45
58
|
|
|
@@ -47,7 +60,13 @@ Each provider exposes immutable metadata:
|
|
|
47
60
|
|
|
48
61
|
```ts
|
|
49
62
|
interface EmbeddingProviderMetadata {
|
|
50
|
-
name:
|
|
63
|
+
name:
|
|
64
|
+
| "local"
|
|
65
|
+
| "cloudflare"
|
|
66
|
+
| "mistral"
|
|
67
|
+
| "gemini"
|
|
68
|
+
| "openai"
|
|
69
|
+
| "openai-compatible";
|
|
51
70
|
model: string;
|
|
52
71
|
dimensions: number;
|
|
53
72
|
batch: {
|
|
@@ -229,6 +248,80 @@ Behavior:
|
|
|
229
248
|
- batch metadata is `{ mode: "native", maxSize: 2048 }`
|
|
230
249
|
- use the same dimension count in `createTable()`
|
|
231
250
|
|
|
251
|
+
## OpenAI-Compatible Endpoints
|
|
252
|
+
|
|
253
|
+
Provider value: `openai-compatible`
|
|
254
|
+
|
|
255
|
+
Use this provider for trusted OpenAI-compatible embedding services such as
|
|
256
|
+
Hugging Face Text Embeddings Inference (TEI) or an internal gateway. This is an
|
|
257
|
+
optional escape hatch; `local` remains the default offline provider, and the
|
|
258
|
+
named hosted providers above are still preferred when their fixed adapters fit.
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
embeddingOptions: {
|
|
262
|
+
provider: "openai-compatible",
|
|
263
|
+
baseUrl: "http://localhost:8080/v1",
|
|
264
|
+
model: "BAAI/bge-large-en-v1.5",
|
|
265
|
+
dimensions: 1024,
|
|
266
|
+
batchSize: 32,
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
If your endpoint requires bearer auth, pass `apiKey` explicitly:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
embeddingOptions: {
|
|
274
|
+
provider: "openai-compatible",
|
|
275
|
+
baseUrl: "https://embeddings.example.com/v1",
|
|
276
|
+
apiKey: process.env.EMBEDDINGS_API_KEY,
|
|
277
|
+
model: "BAAI/bge-large-en-v1.5",
|
|
278
|
+
dimensions: 1024,
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Behavior:
|
|
283
|
+
|
|
284
|
+
- `baseUrl`, `model`, and `dimensions` are required
|
|
285
|
+
- `baseUrl` is the API base, such as `http://localhost:8080/v1`; the library
|
|
286
|
+
sends requests to `/embeddings` below that base
|
|
287
|
+
- a base URL that already ends in `/embeddings` is used as-is
|
|
288
|
+
- only absolute `http` and `https` URLs are accepted
|
|
289
|
+
- URL usernames, passwords, query strings, and fragments are rejected
|
|
290
|
+
- `apiKey` is optional; blank keys are ignored and no `Authorization` header is
|
|
291
|
+
sent
|
|
292
|
+
- `OPENAI_API_KEY` is never read for this provider
|
|
293
|
+
- `batchSize` defaults to `32`, matching TEI's conservative client batch size;
|
|
294
|
+
larger input arrays are split into sequential outbound requests and returned
|
|
295
|
+
in the original input order
|
|
296
|
+
- requests send `{ input, model, dimensions, encoding_format: "float" }`
|
|
297
|
+
- responses must use the standard OpenAI embeddings shape with indexed
|
|
298
|
+
`data[]` items; each response chunk must include unique contiguous indices
|
|
299
|
+
- batch metadata is `{ mode: "native" }`; the internal outbound chunk size is
|
|
300
|
+
not reported as `batch.maxSize`
|
|
301
|
+
|
|
302
|
+
Security boundary:
|
|
303
|
+
|
|
304
|
+
- treat `baseUrl` as trusted server-side configuration only
|
|
305
|
+
- never pass user-supplied request values directly into `baseUrl`
|
|
306
|
+
- use HTTPS for remote endpoints
|
|
307
|
+
- credentials are not sent across redirects
|
|
308
|
+
- non-2xx response bodies are not read, and errors avoid echoing API keys or
|
|
309
|
+
configured endpoint URLs
|
|
310
|
+
|
|
311
|
+
TEI exposes an OpenAI-compatible base at `/v1`, so a local TEI server usually
|
|
312
|
+
uses:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
embeddingOptions: {
|
|
316
|
+
provider: "openai-compatible",
|
|
317
|
+
baseUrl: "http://localhost:8080/v1",
|
|
318
|
+
model: "BAAI/bge-large-en-v1.5",
|
|
319
|
+
dimensions: 1024,
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
This library does not call TEI's native `/embed` endpoint.
|
|
324
|
+
|
|
232
325
|
## Dimension Guidelines
|
|
233
326
|
|
|
234
327
|
- `local` is fixed at `384`
|
|
@@ -238,9 +331,12 @@ Behavior:
|
|
|
238
331
|
`3072`; use `768`, `1536`, or `3072` unless you have a specific reason
|
|
239
332
|
- OpenAI can be used at 1536 or 3072, or another supported OpenAI dimension
|
|
240
333
|
value you explicitly set
|
|
334
|
+
- `openai-compatible` requires you to set the dimension count that your
|
|
335
|
+
endpoint/model actually returns
|
|
241
336
|
|
|
242
|
-
If you switch provider or dimensions for an existing table,
|
|
243
|
-
or rebuild the index into a separate table so stored vectors
|
|
337
|
+
If you switch provider, endpoint, model, or dimensions for an existing table,
|
|
338
|
+
recreate the table or rebuild the index into a separate table so stored vectors
|
|
339
|
+
stay consistent.
|
|
244
340
|
|
|
245
341
|
Existing Gemini indexes created with `text-embedding-004` must be fully
|
|
246
342
|
re-embedded for `gemini-embedding-2`, even if you keep `dimensions: 768`,
|
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,
|
|
7
|
-
|
|
6
|
+
- [Provider guide](./PROVIDERS.md): local, hosted, and OpenAI-compatible
|
|
7
|
+
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,
|