@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/llm",
3
- "version": "2.33.0",
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.29.0"
49
+ "@almadar/core": "^10.31.0"
45
50
  },
46
51
  "devDependencies": {
47
- "@almadar/core": "^10.29.0",
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
+ }