@almadar/llm 2.33.0 → 2.35.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-EELADMF4.js} +79 -5
- package/dist/chunk-EELADMF4.js.map +1 -0
- package/dist/{client-DWcDgr3a.d.ts → client-9G5ND9Uk.d.ts} +42 -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 +8 -3
- package/src/client.ts +145 -6
- 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.35.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,15 +40,16 @@
|
|
|
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"
|
|
42
47
|
},
|
|
43
48
|
"peerDependencies": {
|
|
44
|
-
"@almadar/core": "^10.
|
|
49
|
+
"@almadar/core": "^10.31.0"
|
|
45
50
|
},
|
|
46
51
|
"devDependencies": {
|
|
47
|
-
"@almadar/core": "^10.
|
|
52
|
+
"@almadar/core": "^10.31.0",
|
|
48
53
|
"@almadar/eslint-plugin": "^2.14.0",
|
|
49
54
|
"@types/node": "^22.0.0",
|
|
50
55
|
"@typescript-eslint/parser": "8.56.0",
|
package/src/client.ts
CHANGED
|
@@ -189,6 +189,10 @@ export interface LLMUsage {
|
|
|
189
189
|
promptTokens: number;
|
|
190
190
|
completionTokens: number;
|
|
191
191
|
totalTokens: number;
|
|
192
|
+
/** Prompt tokens served from the provider's prefix cache (OpenAI-style
|
|
193
|
+
* `prompt_tokens_details.cached_tokens`, deepseek-native
|
|
194
|
+
* `prompt_cache_hit_tokens`). Absent when the provider reports neither. */
|
|
195
|
+
cachedPromptTokens?: number;
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
export type LLMFinishReason =
|
|
@@ -217,6 +221,49 @@ export interface LLMStreamChunk {
|
|
|
217
221
|
done: boolean;
|
|
218
222
|
}
|
|
219
223
|
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Vision (Anthropic image content-parts)
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
/** Media types the Anthropic base64 image source accepts. */
|
|
229
|
+
export type VisionImageMediaType = Anthropic.Base64ImageSource['media_type'];
|
|
230
|
+
|
|
231
|
+
/** A single image sent as a base64 content-part (no `data:` URI prefix). */
|
|
232
|
+
export interface VisionImagePart {
|
|
233
|
+
base64: string;
|
|
234
|
+
mediaType: VisionImageMediaType;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface VisionCallOptions<T = string> {
|
|
238
|
+
systemPrompt?: string;
|
|
239
|
+
userText: string;
|
|
240
|
+
images: ReadonlyArray<VisionImagePart>;
|
|
241
|
+
/** Zod schema for structured JSON output — reuses the package's `parseJsonResponse` mechanism. */
|
|
242
|
+
schema?: z.ZodSchema<T>;
|
|
243
|
+
maxTokens?: number;
|
|
244
|
+
temperature?: number;
|
|
245
|
+
skipSchemaValidation?: boolean;
|
|
246
|
+
signal?: AbortSignal;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build the Anthropic user-message content array for a vision call: one text
|
|
251
|
+
* block followed by one image block per screenshot, grounded entirely in the
|
|
252
|
+
* SDK's own typed shapes. Pure and side-effect-free so it can be unit-tested
|
|
253
|
+
* without any API call.
|
|
254
|
+
*/
|
|
255
|
+
export function buildVisionMessageContent(
|
|
256
|
+
userText: string,
|
|
257
|
+
images: ReadonlyArray<VisionImagePart>,
|
|
258
|
+
): Anthropic.ContentBlockParam[] {
|
|
259
|
+
const textBlock: Anthropic.TextBlockParam = { type: 'text', text: userText };
|
|
260
|
+
const imageBlocks: Anthropic.ImageBlockParam[] = images.map((img) => ({
|
|
261
|
+
type: 'image',
|
|
262
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.base64 },
|
|
263
|
+
}));
|
|
264
|
+
return [textBlock, ...imageBlocks];
|
|
265
|
+
}
|
|
266
|
+
|
|
220
267
|
// ============================================================================
|
|
221
268
|
// Provider Configuration
|
|
222
269
|
// ============================================================================
|
|
@@ -353,6 +400,7 @@ export const ANTHROPIC_MODELS = {
|
|
|
353
400
|
CLAUDE_SONNET_4_5: 'claude-sonnet-4-5-20250929',
|
|
354
401
|
CLAUDE_SONNET_4: 'claude-sonnet-4-20250514',
|
|
355
402
|
CLAUDE_OPUS_4_5: 'claude-opus-4-5-20250929',
|
|
403
|
+
CLAUDE_HAIKU_4_5: 'claude-haiku-4-5',
|
|
356
404
|
CLAUDE_3_5_HAIKU: 'claude-3-5-haiku-20241022',
|
|
357
405
|
} as const;
|
|
358
406
|
|
|
@@ -1123,20 +1171,23 @@ export class LLMClient {
|
|
|
1123
1171
|
|
|
1124
1172
|
let usage: LLMUsage | null = null;
|
|
1125
1173
|
if (parsed.usage) {
|
|
1174
|
+
const rawUsage = parsed.usage as {
|
|
1175
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
1176
|
+
prompt_cache_hit_tokens?: number;
|
|
1177
|
+
cost?: number;
|
|
1178
|
+
};
|
|
1179
|
+
const cachedTokens =
|
|
1180
|
+
rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens;
|
|
1126
1181
|
usage = {
|
|
1127
1182
|
promptTokens: parsed.usage.prompt_tokens,
|
|
1128
1183
|
completionTokens: parsed.usage.completion_tokens,
|
|
1129
1184
|
totalTokens: parsed.usage.total_tokens,
|
|
1185
|
+
...(cachedTokens !== undefined ? { cachedPromptTokens: cachedTokens } : {}),
|
|
1130
1186
|
};
|
|
1131
1187
|
if (this.tokenTracker) {
|
|
1132
|
-
const rawUsage = parsed.usage as {
|
|
1133
|
-
prompt_tokens_details?: { cached_tokens?: number };
|
|
1134
|
-
cost?: number;
|
|
1135
|
-
};
|
|
1136
|
-
const cachedTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
1137
1188
|
this.tokenTracker.addUsage(usage.promptTokens, usage.completionTokens, {
|
|
1138
1189
|
provider: this.provider,
|
|
1139
|
-
cachedPromptTokens: cachedTokens,
|
|
1190
|
+
cachedPromptTokens: cachedTokens ?? 0,
|
|
1140
1191
|
// OpenRouter returns the real, routing+cache-adjusted charge here.
|
|
1141
1192
|
...(typeof rawUsage.cost === 'number' ? { costUSD: rawUsage.cost } : {}),
|
|
1142
1193
|
});
|
|
@@ -1377,6 +1428,94 @@ export class LLMClient {
|
|
|
1377
1428
|
throw lastError;
|
|
1378
1429
|
}
|
|
1379
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Send one or more images (base64 content-parts) plus a text prompt to a
|
|
1433
|
+
* vision-capable Anthropic model and return the response. When a `schema`
|
|
1434
|
+
* is supplied the text output is parsed and validated through the package's
|
|
1435
|
+
* `parseJsonResponse` mechanism; otherwise the raw text is returned as `T`.
|
|
1436
|
+
*
|
|
1437
|
+
* Anthropic-only — image content-parts use the native messages API. Other
|
|
1438
|
+
* providers throw (mirrors `callWithTools`).
|
|
1439
|
+
*/
|
|
1440
|
+
async callWithVision<T = string>(
|
|
1441
|
+
options: VisionCallOptions<T>,
|
|
1442
|
+
): Promise<LLMResponse<T>> {
|
|
1443
|
+
if (this.provider !== 'anthropic') {
|
|
1444
|
+
throw new Error(
|
|
1445
|
+
`LLMClient.callWithVision: vision is only supported on the anthropic provider (got ${this.provider})`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const {
|
|
1450
|
+
systemPrompt,
|
|
1451
|
+
userText,
|
|
1452
|
+
images,
|
|
1453
|
+
schema,
|
|
1454
|
+
maxTokens,
|
|
1455
|
+
temperature,
|
|
1456
|
+
skipSchemaValidation = false,
|
|
1457
|
+
signal,
|
|
1458
|
+
} = options;
|
|
1459
|
+
|
|
1460
|
+
if (images.length === 0) {
|
|
1461
|
+
throw new Error('LLMClient.callWithVision: at least one image is required');
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return this.rateLimiter.execute(async () => {
|
|
1465
|
+
const anthropic = new Anthropic({ apiKey: this.providerConfig.apiKey });
|
|
1466
|
+
const content = buildVisionMessageContent(userText, images);
|
|
1467
|
+
const messages: Anthropic.MessageParam[] = [{ role: 'user', content }];
|
|
1468
|
+
|
|
1469
|
+
log.info(
|
|
1470
|
+
`[LLMClient:callWithVision] Invoking ${this.provider}/${this.modelName} (images=${images.length})`,
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
const response = await anthropic.messages.create(
|
|
1474
|
+
{
|
|
1475
|
+
model: this.modelName,
|
|
1476
|
+
max_tokens: maxTokens ?? 4096,
|
|
1477
|
+
temperature: temperature ?? 0,
|
|
1478
|
+
...(systemPrompt ? { system: systemPrompt } : {}),
|
|
1479
|
+
messages,
|
|
1480
|
+
},
|
|
1481
|
+
signal ? { signal } : {},
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1484
|
+
const textContent = response.content.find((c) => c.type === 'text');
|
|
1485
|
+
const rawText =
|
|
1486
|
+
textContent && 'text' in textContent ? textContent.text : '';
|
|
1487
|
+
|
|
1488
|
+
const apiUsage = response.usage;
|
|
1489
|
+
const usage: LLMUsage = {
|
|
1490
|
+
promptTokens: apiUsage.input_tokens,
|
|
1491
|
+
completionTokens: apiUsage.output_tokens,
|
|
1492
|
+
totalTokens: apiUsage.input_tokens + apiUsage.output_tokens,
|
|
1493
|
+
};
|
|
1494
|
+
if (this.tokenTracker) {
|
|
1495
|
+
this.tokenTracker.addUsage(usage.promptTokens, usage.completionTokens, {
|
|
1496
|
+
provider: this.provider,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const finishReason: LLMFinishReason =
|
|
1501
|
+
response.stop_reason === 'end_turn'
|
|
1502
|
+
? 'stop'
|
|
1503
|
+
: response.stop_reason === 'max_tokens'
|
|
1504
|
+
? 'length'
|
|
1505
|
+
: response.stop_reason === 'tool_use'
|
|
1506
|
+
? 'tool_calls'
|
|
1507
|
+
: null;
|
|
1508
|
+
|
|
1509
|
+
const data: T = skipSchemaValidation
|
|
1510
|
+
? parseJsonResponse<T>(rawText, undefined)
|
|
1511
|
+
: schema
|
|
1512
|
+
? parseJsonResponse<T>(rawText, schema)
|
|
1513
|
+
: asGeneric<T>(rawText);
|
|
1514
|
+
|
|
1515
|
+
return { data, raw: rawText, finishReason, usage };
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1380
1519
|
static cacheableBlock(text: string, cache = true): CacheableBlock {
|
|
1381
1520
|
return cache
|
|
1382
1521
|
? { 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
|
+
}
|