@almadar/llm 2.33.0 → 2.34.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/dist/{chunk-EHZE3HBY.js → chunk-JYWZCVND.js} +74 -1
- package/dist/chunk-JYWZCVND.js.map +1 -0
- package/dist/{client-DWcDgr3a.d.ts → client-FtbaoD1M.d.ts} +38 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.js +3 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/vector/index.d.ts +112 -0
- package/dist/vector/index.js +302 -0
- package/dist/vector/index.js.map +1 -0
- package/package.json +6 -1
- package/src/client.ts +132 -0
- package/src/index.ts +4 -0
- package/src/vector/collection.ts +161 -0
- package/src/vector/env.ts +45 -0
- package/src/vector/index.ts +26 -0
- package/src/vector/shape.ts +60 -0
- package/src/vector/store.ts +193 -0
- package/dist/chunk-EHZE3HBY.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@almadar/llm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.34.0",
|
|
4
4
|
"description": "Multi-provider LLM client with rate limiting, token tracking, structured outputs, and continuation handling",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"./providers": {
|
|
26
26
|
"types": "./dist/providers/index.d.ts",
|
|
27
27
|
"import": "./dist/providers/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./vector": {
|
|
30
|
+
"types": "./dist/vector/index.d.ts",
|
|
31
|
+
"import": "./dist/vector/index.js"
|
|
28
32
|
}
|
|
29
33
|
},
|
|
30
34
|
"files": [
|
|
@@ -36,6 +40,7 @@
|
|
|
36
40
|
"@langchain/anthropic": "^1.3.23",
|
|
37
41
|
"@langchain/core": "^1.1.32",
|
|
38
42
|
"@langchain/openai": "^1.2.13",
|
|
43
|
+
"chromadb": "^3.1.0",
|
|
39
44
|
"openai": "^6.18.0",
|
|
40
45
|
"zod": "^3.22.0",
|
|
41
46
|
"@almadar/logger": "^1.9.0"
|
package/src/client.ts
CHANGED
|
@@ -217,6 +217,49 @@ export interface LLMStreamChunk {
|
|
|
217
217
|
done: boolean;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// Vision (Anthropic image content-parts)
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
/** Media types the Anthropic base64 image source accepts. */
|
|
225
|
+
export type VisionImageMediaType = Anthropic.Base64ImageSource['media_type'];
|
|
226
|
+
|
|
227
|
+
/** A single image sent as a base64 content-part (no `data:` URI prefix). */
|
|
228
|
+
export interface VisionImagePart {
|
|
229
|
+
base64: string;
|
|
230
|
+
mediaType: VisionImageMediaType;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface VisionCallOptions<T = string> {
|
|
234
|
+
systemPrompt?: string;
|
|
235
|
+
userText: string;
|
|
236
|
+
images: ReadonlyArray<VisionImagePart>;
|
|
237
|
+
/** Zod schema for structured JSON output — reuses the package's `parseJsonResponse` mechanism. */
|
|
238
|
+
schema?: z.ZodSchema<T>;
|
|
239
|
+
maxTokens?: number;
|
|
240
|
+
temperature?: number;
|
|
241
|
+
skipSchemaValidation?: boolean;
|
|
242
|
+
signal?: AbortSignal;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build the Anthropic user-message content array for a vision call: one text
|
|
247
|
+
* block followed by one image block per screenshot, grounded entirely in the
|
|
248
|
+
* SDK's own typed shapes. Pure and side-effect-free so it can be unit-tested
|
|
249
|
+
* without any API call.
|
|
250
|
+
*/
|
|
251
|
+
export function buildVisionMessageContent(
|
|
252
|
+
userText: string,
|
|
253
|
+
images: ReadonlyArray<VisionImagePart>,
|
|
254
|
+
): Anthropic.ContentBlockParam[] {
|
|
255
|
+
const textBlock: Anthropic.TextBlockParam = { type: 'text', text: userText };
|
|
256
|
+
const imageBlocks: Anthropic.ImageBlockParam[] = images.map((img) => ({
|
|
257
|
+
type: 'image',
|
|
258
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.base64 },
|
|
259
|
+
}));
|
|
260
|
+
return [textBlock, ...imageBlocks];
|
|
261
|
+
}
|
|
262
|
+
|
|
220
263
|
// ============================================================================
|
|
221
264
|
// Provider Configuration
|
|
222
265
|
// ============================================================================
|
|
@@ -353,6 +396,7 @@ export const ANTHROPIC_MODELS = {
|
|
|
353
396
|
CLAUDE_SONNET_4_5: 'claude-sonnet-4-5-20250929',
|
|
354
397
|
CLAUDE_SONNET_4: 'claude-sonnet-4-20250514',
|
|
355
398
|
CLAUDE_OPUS_4_5: 'claude-opus-4-5-20250929',
|
|
399
|
+
CLAUDE_HAIKU_4_5: 'claude-haiku-4-5',
|
|
356
400
|
CLAUDE_3_5_HAIKU: 'claude-3-5-haiku-20241022',
|
|
357
401
|
} as const;
|
|
358
402
|
|
|
@@ -1377,6 +1421,94 @@ export class LLMClient {
|
|
|
1377
1421
|
throw lastError;
|
|
1378
1422
|
}
|
|
1379
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* Send one or more images (base64 content-parts) plus a text prompt to a
|
|
1426
|
+
* vision-capable Anthropic model and return the response. When a `schema`
|
|
1427
|
+
* is supplied the text output is parsed and validated through the package's
|
|
1428
|
+
* `parseJsonResponse` mechanism; otherwise the raw text is returned as `T`.
|
|
1429
|
+
*
|
|
1430
|
+
* Anthropic-only — image content-parts use the native messages API. Other
|
|
1431
|
+
* providers throw (mirrors `callWithTools`).
|
|
1432
|
+
*/
|
|
1433
|
+
async callWithVision<T = string>(
|
|
1434
|
+
options: VisionCallOptions<T>,
|
|
1435
|
+
): Promise<LLMResponse<T>> {
|
|
1436
|
+
if (this.provider !== 'anthropic') {
|
|
1437
|
+
throw new Error(
|
|
1438
|
+
`LLMClient.callWithVision: vision is only supported on the anthropic provider (got ${this.provider})`,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const {
|
|
1443
|
+
systemPrompt,
|
|
1444
|
+
userText,
|
|
1445
|
+
images,
|
|
1446
|
+
schema,
|
|
1447
|
+
maxTokens,
|
|
1448
|
+
temperature,
|
|
1449
|
+
skipSchemaValidation = false,
|
|
1450
|
+
signal,
|
|
1451
|
+
} = options;
|
|
1452
|
+
|
|
1453
|
+
if (images.length === 0) {
|
|
1454
|
+
throw new Error('LLMClient.callWithVision: at least one image is required');
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
return this.rateLimiter.execute(async () => {
|
|
1458
|
+
const anthropic = new Anthropic({ apiKey: this.providerConfig.apiKey });
|
|
1459
|
+
const content = buildVisionMessageContent(userText, images);
|
|
1460
|
+
const messages: Anthropic.MessageParam[] = [{ role: 'user', content }];
|
|
1461
|
+
|
|
1462
|
+
log.info(
|
|
1463
|
+
`[LLMClient:callWithVision] Invoking ${this.provider}/${this.modelName} (images=${images.length})`,
|
|
1464
|
+
);
|
|
1465
|
+
|
|
1466
|
+
const response = await anthropic.messages.create(
|
|
1467
|
+
{
|
|
1468
|
+
model: this.modelName,
|
|
1469
|
+
max_tokens: maxTokens ?? 4096,
|
|
1470
|
+
temperature: temperature ?? 0,
|
|
1471
|
+
...(systemPrompt ? { system: systemPrompt } : {}),
|
|
1472
|
+
messages,
|
|
1473
|
+
},
|
|
1474
|
+
signal ? { signal } : {},
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
const textContent = response.content.find((c) => c.type === 'text');
|
|
1478
|
+
const rawText =
|
|
1479
|
+
textContent && 'text' in textContent ? textContent.text : '';
|
|
1480
|
+
|
|
1481
|
+
const apiUsage = response.usage;
|
|
1482
|
+
const usage: LLMUsage = {
|
|
1483
|
+
promptTokens: apiUsage.input_tokens,
|
|
1484
|
+
completionTokens: apiUsage.output_tokens,
|
|
1485
|
+
totalTokens: apiUsage.input_tokens + apiUsage.output_tokens,
|
|
1486
|
+
};
|
|
1487
|
+
if (this.tokenTracker) {
|
|
1488
|
+
this.tokenTracker.addUsage(usage.promptTokens, usage.completionTokens, {
|
|
1489
|
+
provider: this.provider,
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const finishReason: LLMFinishReason =
|
|
1494
|
+
response.stop_reason === 'end_turn'
|
|
1495
|
+
? 'stop'
|
|
1496
|
+
: response.stop_reason === 'max_tokens'
|
|
1497
|
+
? 'length'
|
|
1498
|
+
: response.stop_reason === 'tool_use'
|
|
1499
|
+
? 'tool_calls'
|
|
1500
|
+
: null;
|
|
1501
|
+
|
|
1502
|
+
const data: T = skipSchemaValidation
|
|
1503
|
+
? parseJsonResponse<T>(rawText, undefined)
|
|
1504
|
+
: schema
|
|
1505
|
+
? parseJsonResponse<T>(rawText, schema)
|
|
1506
|
+
: asGeneric<T>(rawText);
|
|
1507
|
+
|
|
1508
|
+
return { data, raw: rawText, finishReason, usage };
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1380
1512
|
static cacheableBlock(text: string, cache = true): CacheableBlock {
|
|
1381
1513
|
return cache
|
|
1382
1514
|
? { type: 'text', text, cache_control: { type: 'ephemeral' } }
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
createZhipuClient,
|
|
23
23
|
getAvailableProvider,
|
|
24
24
|
isProviderAvailable,
|
|
25
|
+
buildVisionMessageContent,
|
|
25
26
|
DEEPSEEK_MODELS,
|
|
26
27
|
OPENAI_MODELS,
|
|
27
28
|
ANTHROPIC_MODELS,
|
|
@@ -38,6 +39,9 @@ export {
|
|
|
38
39
|
type CacheAwareLLMCallOptions,
|
|
39
40
|
type LLMStreamOptions,
|
|
40
41
|
type LLMStreamChunk,
|
|
42
|
+
type VisionImageMediaType,
|
|
43
|
+
type VisionImagePart,
|
|
44
|
+
type VisionCallOptions,
|
|
41
45
|
} from './client.js';
|
|
42
46
|
|
|
43
47
|
export {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VectorCollection — thin wrapper over a live chromadb `Collection`: batched
|
|
3
|
+
* writes, query shaping, and space/metadata read-through. Mechanics only —
|
|
4
|
+
* callers own retry/best-effort policy, so failures propagate as rejections
|
|
5
|
+
* here (unlike the connect/collection-resolution seams, which never throw).
|
|
6
|
+
*/
|
|
7
|
+
import type * as Chroma from 'chromadb';
|
|
8
|
+
import { normalizeVectorTopK, shapeVectorHits, type VectorHit } from './shape.js';
|
|
9
|
+
|
|
10
|
+
export interface VectorRecords {
|
|
11
|
+
ids: readonly string[];
|
|
12
|
+
embeddings: readonly (readonly number[])[];
|
|
13
|
+
documents?: readonly string[];
|
|
14
|
+
metadatas?: readonly Chroma.Metadata[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface VectorQueryOptions {
|
|
18
|
+
embedding: readonly number[];
|
|
19
|
+
topK?: number;
|
|
20
|
+
where?: Chroma.Where;
|
|
21
|
+
includeDocuments?: boolean;
|
|
22
|
+
includeMetadatas?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VectorDeleteTarget {
|
|
26
|
+
ids?: readonly string[];
|
|
27
|
+
where?: Chroma.Where;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface VectorGetOptions {
|
|
31
|
+
where?: Chroma.Where;
|
|
32
|
+
/** Default true. */
|
|
33
|
+
includeMetadatas?: boolean;
|
|
34
|
+
/** Default false — privacy default: raw node text is never returned unless explicitly asked for. */
|
|
35
|
+
includeDocuments?: boolean;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VectorRecord {
|
|
40
|
+
id: string;
|
|
41
|
+
metadata?: Chroma.Metadata | undefined;
|
|
42
|
+
document?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VectorCollection {
|
|
46
|
+
upsert(records: VectorRecords): Promise<number>;
|
|
47
|
+
add(records: VectorRecords): Promise<number>;
|
|
48
|
+
query(opts: VectorQueryOptions): Promise<VectorHit[]>;
|
|
49
|
+
/** Fetch records by metadata filter (no embedding / no similarity). Defaults to metadatas-only. */
|
|
50
|
+
get(opts?: VectorGetOptions): Promise<VectorRecord[]>;
|
|
51
|
+
delete(target: VectorDeleteTarget): Promise<void>;
|
|
52
|
+
count(): Promise<number>;
|
|
53
|
+
/** Every id in the collection (paged get, vectors not fetched) — backs mirror-sync reconciliation. */
|
|
54
|
+
listIds(): Promise<string[]>;
|
|
55
|
+
readonly metadata: Chroma.CollectionMetadata | undefined;
|
|
56
|
+
readonly space: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const WRITE_BATCH_SIZE = 500;
|
|
60
|
+
|
|
61
|
+
interface WriteBatch {
|
|
62
|
+
ids: string[];
|
|
63
|
+
embeddings: number[][];
|
|
64
|
+
documents?: string[];
|
|
65
|
+
metadatas?: Chroma.Metadata[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeInBatches(
|
|
69
|
+
write: (batch: WriteBatch) => Promise<void>,
|
|
70
|
+
records: VectorRecords,
|
|
71
|
+
): Promise<number> {
|
|
72
|
+
const { ids, embeddings, documents, metadatas } = records;
|
|
73
|
+
let written = 0;
|
|
74
|
+
for (let start = 0; start < ids.length; start += WRITE_BATCH_SIZE) {
|
|
75
|
+
const end = Math.min(start + WRITE_BATCH_SIZE, ids.length);
|
|
76
|
+
await write({
|
|
77
|
+
ids: ids.slice(start, end),
|
|
78
|
+
embeddings: embeddings.slice(start, end).map((vec) => [...vec]),
|
|
79
|
+
...(documents ? { documents: documents.slice(start, end) } : {}),
|
|
80
|
+
...(metadatas ? { metadatas: metadatas.slice(start, end) } : {}),
|
|
81
|
+
});
|
|
82
|
+
written += end - start;
|
|
83
|
+
}
|
|
84
|
+
return written;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function wrapVectorCollection(raw: Chroma.Collection): VectorCollection {
|
|
88
|
+
return {
|
|
89
|
+
upsert(records) {
|
|
90
|
+
return writeInBatches((batch) => raw.upsert(batch), records);
|
|
91
|
+
},
|
|
92
|
+
add(records) {
|
|
93
|
+
return writeInBatches((batch) => raw.add(batch), records);
|
|
94
|
+
},
|
|
95
|
+
async query(opts) {
|
|
96
|
+
const include: Array<'distances' | 'metadatas' | 'documents'> = ['distances'];
|
|
97
|
+
if (opts.includeMetadatas) include.push('metadatas');
|
|
98
|
+
if (opts.includeDocuments) include.push('documents');
|
|
99
|
+
const result = await raw.query({
|
|
100
|
+
queryEmbeddings: [[...opts.embedding]],
|
|
101
|
+
nResults: normalizeVectorTopK(opts.topK),
|
|
102
|
+
where: opts.where,
|
|
103
|
+
include,
|
|
104
|
+
});
|
|
105
|
+
return shapeVectorHits(result);
|
|
106
|
+
},
|
|
107
|
+
async delete(target) {
|
|
108
|
+
await raw.delete({
|
|
109
|
+
...(target.ids ? { ids: [...target.ids] } : {}),
|
|
110
|
+
...(target.where ? { where: target.where } : {}),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
async get(opts = {}) {
|
|
114
|
+
const include: Array<'metadatas' | 'documents'> = [];
|
|
115
|
+
if (opts.includeMetadatas !== false) include.push('metadatas');
|
|
116
|
+
if (opts.includeDocuments) include.push('documents');
|
|
117
|
+
const records: VectorRecord[] = [];
|
|
118
|
+
const PAGE = 10_000;
|
|
119
|
+
const hardLimit = opts.limit ?? Number.MAX_SAFE_INTEGER;
|
|
120
|
+
for (let offset = 0; offset < hardLimit; offset += PAGE) {
|
|
121
|
+
const pageLimit = Math.min(PAGE, hardLimit - offset);
|
|
122
|
+
const page = await raw.get({
|
|
123
|
+
...(opts.where ? { where: opts.where } : {}),
|
|
124
|
+
include,
|
|
125
|
+
limit: pageLimit,
|
|
126
|
+
offset,
|
|
127
|
+
});
|
|
128
|
+
const wantMeta = include.includes('metadatas');
|
|
129
|
+
const wantDoc = include.includes('documents');
|
|
130
|
+
for (let i = 0; i < page.ids.length; i++) {
|
|
131
|
+
records.push({
|
|
132
|
+
id: page.ids[i],
|
|
133
|
+
...(wantMeta ? { metadata: page.metadatas?.[i] ?? undefined } : {}),
|
|
134
|
+
...(wantDoc ? { document: page.documents?.[i] ?? undefined } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (page.ids.length < pageLimit) break;
|
|
138
|
+
}
|
|
139
|
+
return records;
|
|
140
|
+
},
|
|
141
|
+
count() {
|
|
142
|
+
return raw.count();
|
|
143
|
+
},
|
|
144
|
+
async listIds() {
|
|
145
|
+
const ids: string[] = [];
|
|
146
|
+
const PAGE = 10_000;
|
|
147
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
148
|
+
const page = await raw.get({ include: [], limit: PAGE, offset });
|
|
149
|
+
ids.push(...page.ids);
|
|
150
|
+
if (page.ids.length < PAGE) break;
|
|
151
|
+
}
|
|
152
|
+
return ids;
|
|
153
|
+
},
|
|
154
|
+
get metadata() {
|
|
155
|
+
return raw.metadata;
|
|
156
|
+
},
|
|
157
|
+
get space() {
|
|
158
|
+
return raw.configuration.hnsw?.space ?? null;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vector-store env resolution + host parsing — pure seams, no chromadb import.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface VectorStoreConfig {
|
|
6
|
+
host?: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
tenant?: string;
|
|
9
|
+
database?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cleanEnvValue(value: string | undefined): string | undefined {
|
|
13
|
+
const trimmed = value?.trim();
|
|
14
|
+
return trimmed ? trimmed : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveVectorStoreEnv(): VectorStoreConfig {
|
|
18
|
+
const env = process.env;
|
|
19
|
+
return {
|
|
20
|
+
host: cleanEnvValue(env.ALMADAR_CHROMA_HOST),
|
|
21
|
+
apiKey: cleanEnvValue(env.ALMADAR_CHROMA_API_KEY),
|
|
22
|
+
tenant: cleanEnvValue(env.ALMADAR_CHROMA_TENANT),
|
|
23
|
+
database: cleanEnvValue(env.ALMADAR_CHROMA_DATABASE),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VectorHostInit {
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
ssl?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse a bare host or full URL into chromadb client args: bare host ⇒ port 8000, https default 443. */
|
|
34
|
+
export function parseVectorHost(host: string): VectorHostInit {
|
|
35
|
+
const init: VectorHostInit = {};
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(host.includes('://') ? host : `http://${host}`);
|
|
38
|
+
init.host = url.hostname;
|
|
39
|
+
init.port = url.port ? parseInt(url.port, 10) : url.protocol === 'https:' ? 443 : 8000;
|
|
40
|
+
init.ssl = url.protocol === 'https:';
|
|
41
|
+
} catch {
|
|
42
|
+
init.host = host;
|
|
43
|
+
}
|
|
44
|
+
return init;
|
|
45
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @almadar/llm/vector
|
|
3
|
+
*
|
|
4
|
+
* Shared Chroma vector-store client seam: env resolution, connect + cache,
|
|
5
|
+
* collection resolution with a space-mismatch guard, and query-result
|
|
6
|
+
* shaping. Mechanics only — it never embeds (`EmbeddingClient` is its
|
|
7
|
+
* sibling in this package), never names collections, and applies no domain
|
|
8
|
+
* policy beyond what a call site's `VectorCollectionSpec` declares.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { resolveVectorStoreEnv, parseVectorHost, type VectorStoreConfig } from './env.js';
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
connectVectorStore,
|
|
17
|
+
resetVectorStoreCache,
|
|
18
|
+
type VectorStoreHandle,
|
|
19
|
+
type VectorCollectionSpec,
|
|
20
|
+
} from './store.js';
|
|
21
|
+
|
|
22
|
+
export { type VectorCollection, type VectorRecords, type VectorQueryOptions, type VectorGetOptions, type VectorRecord } from './collection.js';
|
|
23
|
+
|
|
24
|
+
export { type VectorHit } from './shape.js';
|
|
25
|
+
|
|
26
|
+
export type { Where, Metadata } from 'chromadb';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-result shaping — pure seams, no chromadb import (types only, erased).
|
|
3
|
+
*/
|
|
4
|
+
import type * as Chroma from 'chromadb';
|
|
5
|
+
|
|
6
|
+
export interface VectorHit {
|
|
7
|
+
id: string;
|
|
8
|
+
distance: number;
|
|
9
|
+
score: number;
|
|
10
|
+
metadata?: Chroma.Metadata;
|
|
11
|
+
document?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_QUERY_TOP_K = 10;
|
|
15
|
+
|
|
16
|
+
export function normalizeVectorTopK(topK: number | undefined): number {
|
|
17
|
+
if (typeof topK !== 'number' || !Number.isFinite(topK) || topK <= 0) return DEFAULT_QUERY_TOP_K;
|
|
18
|
+
return Math.floor(topK);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clamp01(value: number): number {
|
|
22
|
+
if (value < 0) return 0;
|
|
23
|
+
if (value > 1) return 1;
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Structural subset of chromadb's `QueryResult` this module reads (pure-shaping seam). */
|
|
28
|
+
export interface VectorQueryResultLike {
|
|
29
|
+
readonly ids: ReadonlyArray<ReadonlyArray<string>>;
|
|
30
|
+
readonly distances: ReadonlyArray<ReadonlyArray<number | null>>;
|
|
31
|
+
readonly metadatas?: ReadonlyArray<ReadonlyArray<Chroma.Metadata | null>>;
|
|
32
|
+
readonly documents?: ReadonlyArray<ReadonlyArray<string | null>>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shapeVectorHits(result: VectorQueryResultLike): VectorHit[] {
|
|
36
|
+
const ids = result.ids[0] ?? [];
|
|
37
|
+
const distances = result.distances[0] ?? [];
|
|
38
|
+
const metadatas = result.metadatas?.[0] ?? [];
|
|
39
|
+
const documents = result.documents?.[0] ?? [];
|
|
40
|
+
const hits: VectorHit[] = [];
|
|
41
|
+
for (let i = 0; i < ids.length; i++) {
|
|
42
|
+
const id = ids[i];
|
|
43
|
+
if (typeof id !== 'string') continue;
|
|
44
|
+
const rawDistance = distances[i];
|
|
45
|
+
// a missing/null distance is the worst case (no similarity signal) — treat as 1, same as rabit's shapeChromaMatches.
|
|
46
|
+
const distance = typeof rawDistance === 'number' ? rawDistance : 1;
|
|
47
|
+
const score = clamp01(1 - distance);
|
|
48
|
+
const metadata = metadatas[i];
|
|
49
|
+
const document = documents[i];
|
|
50
|
+
hits.push({
|
|
51
|
+
id,
|
|
52
|
+
distance,
|
|
53
|
+
score,
|
|
54
|
+
...(metadata ? { metadata } : {}),
|
|
55
|
+
...(typeof document === 'string' ? { document } : {}),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
hits.sort((a, b) => b.score - a.score);
|
|
59
|
+
return hits;
|
|
60
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vector-store connection + collection resolution: env → chromadb client
|
|
3
|
+
* (cached per process, per distinct config), plus the space-mismatch policy
|
|
4
|
+
* that guards a `collection()` call from silently ranking/writing under the
|
|
5
|
+
* wrong index space.
|
|
6
|
+
*/
|
|
7
|
+
import type * as Chroma from 'chromadb';
|
|
8
|
+
import { createLogger } from '@almadar/logger';
|
|
9
|
+
import type { VectorStoreConfig } from './env.js';
|
|
10
|
+
import { parseVectorHost } from './env.js';
|
|
11
|
+
import { wrapVectorCollection, type VectorCollection } from './collection.js';
|
|
12
|
+
|
|
13
|
+
const vectorLog = createLogger('almadar:llm:vector');
|
|
14
|
+
|
|
15
|
+
function errorMessage(err: unknown): string {
|
|
16
|
+
return err instanceof Error ? err.message : String(err);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type VectorSpace = 'cosine' | 'l2' | 'ip';
|
|
20
|
+
|
|
21
|
+
export interface VectorCollectionSpec {
|
|
22
|
+
name: string;
|
|
23
|
+
space?: VectorSpace;
|
|
24
|
+
metadata?: Chroma.CollectionMetadata;
|
|
25
|
+
/** 'recreate' drops + rebuilds a wrong-space collection (derived/rebuildable data only). Default 'reject' never auto-drops a consumer's data. */
|
|
26
|
+
onSpaceMismatch?: 'recreate' | 'reject';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VectorStoreHandle {
|
|
30
|
+
/** Human-readable connection target, for logs. */
|
|
31
|
+
readonly target: string;
|
|
32
|
+
collection(spec: VectorCollectionSpec): Promise<VectorCollection | null>;
|
|
33
|
+
getCollection(name: string): Promise<VectorCollection | null>;
|
|
34
|
+
deleteCollection(name: string): Promise<boolean>;
|
|
35
|
+
heartbeat(): Promise<boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stubEmbeddingFunction: Chroma.EmbeddingFunction = {
|
|
39
|
+
// Callers always supply pre-computed vectors; the stub stops chromadb from
|
|
40
|
+
// instantiating its own DefaultEmbeddingFunction.
|
|
41
|
+
generate: async () => [],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEFAULT_ON_SPACE_MISMATCH = 'reject';
|
|
45
|
+
|
|
46
|
+
function isEmptyConfig(config: VectorStoreConfig): boolean {
|
|
47
|
+
return !config.host && !config.apiKey;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function configCacheKey(config: VectorStoreConfig): string {
|
|
51
|
+
return JSON.stringify([config.host ?? null, config.apiKey ?? null, config.tenant ?? null, config.database ?? null]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadChromaModule(): Promise<typeof import('chromadb') | null> {
|
|
55
|
+
try {
|
|
56
|
+
return await import('chromadb');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
vectorLog.warn('store:module-missing', () => ({
|
|
59
|
+
error: errorMessage(err),
|
|
60
|
+
hint: 'add chromadb to dependencies and run pnpm install',
|
|
61
|
+
}));
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function resolveCollection(
|
|
67
|
+
client: Chroma.ChromaClient,
|
|
68
|
+
spec: VectorCollectionSpec,
|
|
69
|
+
): Promise<VectorCollection | null> {
|
|
70
|
+
const create = (): Promise<Chroma.Collection> =>
|
|
71
|
+
client.getOrCreateCollection({
|
|
72
|
+
name: spec.name,
|
|
73
|
+
embeddingFunction: stubEmbeddingFunction,
|
|
74
|
+
...(spec.space ? { configuration: { hnsw: { space: spec.space } } } : {}),
|
|
75
|
+
...(spec.metadata ? { metadata: spec.metadata } : {}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let raw: Chroma.Collection;
|
|
79
|
+
try {
|
|
80
|
+
raw = await create();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
vectorLog.warn('collection:create-failed', () => ({ name: spec.name, error: errorMessage(err) }));
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// spec.space omitted ⇒ no verification, server default on create.
|
|
87
|
+
if (!spec.space) return wrapVectorCollection(raw);
|
|
88
|
+
|
|
89
|
+
const foundSpace = raw.configuration.hnsw?.space ?? null;
|
|
90
|
+
if (foundSpace === spec.space) return wrapVectorCollection(raw);
|
|
91
|
+
|
|
92
|
+
const policy = spec.onSpaceMismatch ?? DEFAULT_ON_SPACE_MISMATCH;
|
|
93
|
+
if (policy === 'reject') {
|
|
94
|
+
vectorLog.warn('collection:space-mismatch', () => ({
|
|
95
|
+
name: spec.name,
|
|
96
|
+
declaredSpace: spec.space,
|
|
97
|
+
foundSpace,
|
|
98
|
+
policy,
|
|
99
|
+
}));
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
vectorLog.warn('collection:space-heal', () => ({ name: spec.name, declaredSpace: spec.space, foundSpace }));
|
|
104
|
+
await client.deleteCollection({ name: spec.name });
|
|
105
|
+
try {
|
|
106
|
+
raw = await create();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
vectorLog.warn('collection:create-failed', () => ({ name: spec.name, error: errorMessage(err) }));
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return wrapVectorCollection(raw);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildVectorStoreHandle(client: Chroma.ChromaClient, target: string): VectorStoreHandle {
|
|
115
|
+
return {
|
|
116
|
+
target,
|
|
117
|
+
collection(spec) {
|
|
118
|
+
return resolveCollection(client, spec);
|
|
119
|
+
},
|
|
120
|
+
async getCollection(name) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = await client.getCollection({ name, embeddingFunction: stubEmbeddingFunction });
|
|
123
|
+
return wrapVectorCollection(raw);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
vectorLog.debug('collection:get-missing', () => ({ name, error: errorMessage(err) }));
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
async deleteCollection(name) {
|
|
130
|
+
try {
|
|
131
|
+
await client.deleteCollection({ name });
|
|
132
|
+
return true;
|
|
133
|
+
} catch (err) {
|
|
134
|
+
vectorLog.warn('collection:delete-failed', () => ({ name, error: errorMessage(err) }));
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
async heartbeat() {
|
|
139
|
+
try {
|
|
140
|
+
await client.heartbeat();
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const vectorStoreCache = new Map<string, VectorStoreHandle>();
|
|
150
|
+
|
|
151
|
+
/** Test-only reset — clears the per-process handle cache between cases. */
|
|
152
|
+
export function resetVectorStoreCache(): void {
|
|
153
|
+
vectorStoreCache.clear();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function connectVectorStore(config: VectorStoreConfig): Promise<VectorStoreHandle | null> {
|
|
157
|
+
if (isEmptyConfig(config)) {
|
|
158
|
+
vectorLog.debug('store:skip', () => ({ reason: 'config-empty' }));
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const key = configCacheKey(config);
|
|
163
|
+
const cached = vectorStoreCache.get(key);
|
|
164
|
+
if (cached) return cached;
|
|
165
|
+
|
|
166
|
+
const mod = await loadChromaModule();
|
|
167
|
+
if (!mod) return null;
|
|
168
|
+
|
|
169
|
+
let client: Chroma.ChromaClient;
|
|
170
|
+
let target: string;
|
|
171
|
+
if (config.apiKey) {
|
|
172
|
+
client = new mod.CloudClient({ apiKey: config.apiKey, tenant: config.tenant, database: config.database });
|
|
173
|
+
target = `cloud(tenant=${config.tenant ?? 'default'}, db=${config.database ?? 'default'})`;
|
|
174
|
+
} else if (config.host) {
|
|
175
|
+
client = new mod.ChromaClient(parseVectorHost(config.host));
|
|
176
|
+
target = config.host;
|
|
177
|
+
} else {
|
|
178
|
+
// unreachable: isEmptyConfig already guards host/apiKey both absent.
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await client.heartbeat();
|
|
184
|
+
} catch (err) {
|
|
185
|
+
vectorLog.warn('store:heartbeat-failed', () => ({ target, error: errorMessage(err) }));
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const handle = buildVectorStoreHandle(client, target);
|
|
190
|
+
vectorStoreCache.set(key, handle);
|
|
191
|
+
vectorLog.info('store:ready', () => ({ target }));
|
|
192
|
+
return handle;
|
|
193
|
+
}
|