@almadar/llm 2.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@almadar/llm",
3
- "version": "2.32.0",
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' } }
@@ -57,6 +57,10 @@ interface OpenAIEmbeddingApiResponse {
57
57
  const EMBED_MAX_ATTEMPTS = 3;
58
58
  const EMBED_RETRY_BACKOFF_MS = [500, 2000];
59
59
 
60
+ /** Provider rejects an input that exceeds the model's context window (bge: 512 tokens). */
61
+ const TOKEN_OVERFLOW_RE =
62
+ /context length|maximum input length|reduce the length of the input|input_tokens/i;
63
+
60
64
  const DEFAULT_MODELS: Record<EmbeddingProvider, string> = {
61
65
  openai: 'text-embedding-3-small',
62
66
  openrouter: 'baai/bge-base-en-v1.5',
@@ -112,17 +116,24 @@ export class EmbeddingClient {
112
116
  if (texts.length === 0) {
113
117
  return { embeddings: [], usage: { promptTokens: 0, totalTokens: 0 } };
114
118
  }
115
- // bge-base-en-v1.5 has a 512-token context limit; trim each input so
116
- // providers (notably OpenRouter) don't return a malformed response body.
117
- // 2000 chars 500 tokens for typical English text same cap used by
118
- // the runtime's tryEmbedRequest helper.
119
- const trimmed = texts.map((t) => (t.length > 2000 ? t.slice(0, 2000) : t));
119
+ // bge-base-en-v1.5 has a 512-token context limit. Chars-per-token varies
120
+ // with density (~4 for typical English, ~2.5-3 for dense technical/code
121
+ // text), so no fixed char cap can guarantee a fit. Start at 2000 chars and,
122
+ // when the provider rejects an input for exceeding the context window,
123
+ // halve the cap and retry the provider's own token count is the source
124
+ // of truth, not a chars-per-token guess.
125
+ let charCap = 2000;
120
126
  let lastError: Error = new Error('EmbeddingClient: unreachable');
121
127
  for (let attempt = 1; attempt <= EMBED_MAX_ATTEMPTS; attempt++) {
128
+ const trimmed = texts.map((t) => (t.length > charCap ? t.slice(0, charCap) : t));
122
129
  try {
123
130
  return await this.embedBatchOnce(texts.length, trimmed);
124
131
  } catch (e) {
125
132
  lastError = e instanceof Error ? e : new Error(String(e));
133
+ if (TOKEN_OVERFLOW_RE.test(lastError.message) && charCap > 500) {
134
+ charCap = Math.floor(charCap / 2);
135
+ continue;
136
+ }
126
137
  const backoff = EMBED_RETRY_BACKOFF_MS[attempt - 1];
127
138
  if (backoff === undefined) break;
128
139
  await new Promise((r) => setTimeout(r, backoff));
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
+ }