@devilsdev/rag-pipeline-utils 2.5.0 → 2.6.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +1 -1
  3. package/dist/index.cjs +1484 -147
  4. package/dist/index.d.ts +49 -0
  5. package/dist/index.mjs +1 -1
  6. package/dist/testing.cjs +2 -0
  7. package/dist/testing.d.ts +1 -0
  8. package/dist/testing.mjs +5 -0
  9. package/dist/types/adapters/ai-sdk.d.ts +53 -0
  10. package/dist/types/cache/component-keys.d.ts +7 -0
  11. package/dist/types/cache/memory-cache.d.ts +54 -0
  12. package/dist/types/cache/pipeline-cache.d.ts +66 -0
  13. package/dist/types/cache/redis-cache.d.ts +46 -0
  14. package/dist/types/connectors/anthropic-connector.d.ts +7 -2
  15. package/dist/types/connectors/cohere-connector.d.ts +7 -2
  16. package/dist/types/connectors/local-embedder.d.ts +9 -0
  17. package/dist/types/connectors/memory-retriever.d.ts +8 -0
  18. package/dist/types/connectors/ollama-connector.d.ts +7 -2
  19. package/dist/types/connectors/openai-connector.d.ts +16 -1
  20. package/dist/types/core/create-pipeline.d.ts +8 -0
  21. package/dist/types/core/plugin-registry.d.ts +136 -0
  22. package/dist/types/core/stage-contracts.d.ts +36 -0
  23. package/dist/types/core/validate-plugin.d.ts +45 -0
  24. package/dist/types/evaluate/semantic-similarity.d.ts +14 -0
  25. package/dist/types/grounding/check-answer.d.ts +101 -0
  26. package/dist/types/retrieval/file-vector-store.d.ts +12 -0
  27. package/dist/types/testing.d.ts +168 -0
  28. package/package.json +22 -7
  29. package/src/adapters/ai-sdk.js +215 -0
  30. package/src/cache/component-keys.js +38 -0
  31. package/src/cache/memory-cache.js +87 -0
  32. package/src/cache/pipeline-cache.js +260 -0
  33. package/src/cache/redis-cache.js +83 -0
  34. package/src/cli/commands/ai.js +17 -12
  35. package/src/cli/commands/plugin-hub.js +18 -13
  36. package/src/cli/enhanced-cli-commands.js +45 -29
  37. package/src/cli/handlers/query-handler.js +174 -11
  38. package/src/cli/services/instrument.js +83 -0
  39. package/src/cli/services/rag-service.js +51 -12
  40. package/src/cli/wizard/init-wizard.js +2 -17
  41. package/src/config/enhanced-ragrc-schema.js +0 -2
  42. package/src/config/validate-schema.js +4 -1
  43. package/src/connectors/anthropic-connector.js +22 -2
  44. package/src/connectors/cohere-connector.js +20 -2
  45. package/src/connectors/local-embedder.js +20 -0
  46. package/src/connectors/memory-retriever.js +18 -0
  47. package/src/connectors/ollama-connector.js +29 -2
  48. package/src/connectors/openai-connector.js +49 -4
  49. package/src/core/create-pipeline.js +112 -15
  50. package/src/core/performance/parallel-processor.js +62 -1
  51. package/src/core/plugin-registry.js +42 -19
  52. package/src/core/stage-contracts.js +36 -0
  53. package/src/core/validate-plugin.js +85 -0
  54. package/src/debug/execution-tracer.js +14 -4
  55. package/src/dev/dev-server.js +8 -1
  56. package/src/dev/hot-reload.js +4 -3
  57. package/src/enterprise/sso-integration.js +4 -3
  58. package/src/evaluate/semantic-similarity.js +79 -0
  59. package/src/grounding/check-answer.js +173 -0
  60. package/src/index.js +22 -0
  61. package/src/loader/csv-loader.js +3 -2
  62. package/src/loader/html-loader.js +3 -1
  63. package/src/loader/markdown-loader.js +3 -2
  64. package/src/retrieval/file-vector-store.js +30 -1
  65. package/src/security/jwt-validator.js +5 -4
  66. package/src/testing.js +254 -0
@@ -0,0 +1,45 @@
1
+ export type PluginValidationResult = {
2
+ /**
3
+ * - True when there are no errors
4
+ */
5
+ valid: boolean;
6
+ /**
7
+ * - Problems that stop the plugin from working
8
+ */
9
+ errors: string[];
10
+ /**
11
+ * - Methods some commands need, e.g. `store`
12
+ * for `rag-pipeline ingest`
13
+ */
14
+ warnings: string[];
15
+ };
16
+ /**
17
+ * @typedef {Object} PluginValidationResult
18
+ * @property {boolean} valid - True when there are no errors
19
+ * @property {string[]} errors - Problems that stop the plugin from working
20
+ * @property {string[]} warnings - Methods some commands need, e.g. `store`
21
+ * for `rag-pipeline ingest`
22
+ */
23
+ /**
24
+ * Checks that an object can be used as a pipeline stage, without throwing,
25
+ * so plugin authors can assert on it in their own tests.
26
+ *
27
+ * By default it checks what createRagPipeline() calls. With
28
+ * `{ registry: true }` it also applies the rules pluginRegistry.register()
29
+ * enforces: `metadata` with a string `name` and `version` and a `type`
30
+ * equal to the stage, plus the methods in the stage's contract.
31
+ *
32
+ * @example
33
+ * const { validatePlugin } = require('@devilsdev/rag-pipeline-utils');
34
+ * const result = validatePlugin('llm', myLlm, { registry: true });
35
+ * expect(result.errors).toEqual([]);
36
+ *
37
+ * @param {'loader'|'embedder'|'retriever'|'reranker'|'llm'} stage
38
+ * @param {object} plugin - Plugin instance or object
39
+ * @param {Object} [options]
40
+ * @param {boolean} [options.registry=false] - Also check registry rules
41
+ * @returns {PluginValidationResult}
42
+ */
43
+ export function validatePlugin(stage: "loader" | "embedder" | "retriever" | "reranker" | "llm", plugin: object, options?: {
44
+ registry?: boolean;
45
+ }): PluginValidationResult;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @param {string} answer - The generated answer
3
+ * @param {string} reference - The reference (ground-truth) answer
4
+ * @param {Object} options
5
+ * @param {Object} options.embedder - Any embedder with embed() or embedQuery()
6
+ * (a built-in connector, LocalEmbedder fitted to your corpus, an AI SDK
7
+ * adapter, HashEmbedder from /testing)
8
+ * @param {AbortSignal} [options.signal]
9
+ * @returns {Promise<number>} Cosine similarity clamped to 0..1
10
+ */
11
+ export function computeSemanticSimilarity(answer: string, reference: string, { embedder, signal }?: {
12
+ embedder: any;
13
+ signal?: AbortSignal;
14
+ }): Promise<number>;
@@ -0,0 +1,101 @@
1
+ export type AnswerCheck = {
2
+ /**
3
+ * - Every sentence is supported by a source
4
+ */
5
+ grounded: boolean;
6
+ /**
7
+ * - Share of supported sentences, 0..1
8
+ */
9
+ groundedness: number;
10
+ /**
11
+ * - Sentences no source supports (this
12
+ * includes a sentence whose numbers do not match its source)
13
+ */
14
+ unsupported: string[];
15
+ /**
16
+ * - Sources
17
+ * supporting each sentence
18
+ */
19
+ citations: Array<{
20
+ sentence: string;
21
+ sources: any[];
22
+ }>;
23
+ /**
24
+ * - Faithfulness, relevance, context precision (and context recall when a
25
+ * referenceAnswer is given); only when `evaluate` is not false
26
+ */
27
+ evaluation: {
28
+ scores: any;
29
+ details: any;
30
+ metadata: any;
31
+ } | undefined;
32
+ /**
33
+ * - The sources as they were checked
34
+ */
35
+ sources: Array<{
36
+ id: string;
37
+ content: string;
38
+ metadata: any;
39
+ }>;
40
+ };
41
+ /**
42
+ * @typedef {Object} AnswerCheck
43
+ * @property {boolean} grounded - Every sentence is supported by a source
44
+ * @property {number} groundedness - Share of supported sentences, 0..1
45
+ * @property {string[]} unsupported - Sentences no source supports (this
46
+ * includes a sentence whose numbers do not match its source)
47
+ * @property {Array<{sentence: string, sources: Array}>} citations - Sources
48
+ * supporting each sentence
49
+ * @property {{scores: Object, details: Object, metadata: Object}|undefined} evaluation
50
+ * - Faithfulness, relevance, context precision (and context recall when a
51
+ * referenceAnswer is given); only when `evaluate` is not false
52
+ * @property {Array<{id: string, content: string, metadata: Object}>} sources
53
+ * - The sources as they were checked
54
+ */
55
+ /**
56
+ * Check an answer against the sources it was generated from.
57
+ *
58
+ * @example
59
+ * // A LangChain chain's output
60
+ * const check = await checkAnswer({
61
+ * question: input,
62
+ * answer: result.answer,
63
+ * sources: result.context, // LangChain Documents
64
+ * });
65
+ * if (!check.grounded) console.warn(check.unsupported);
66
+ *
67
+ * @param {Object} params
68
+ * @param {string} params.answer - The generated answer
69
+ * @param {Array<string|Object>} params.sources - Documents the answer was
70
+ * generated from (see normalizeSources for accepted shapes)
71
+ * @param {string} [params.question] - The question; needed for relevance
72
+ * @param {string} [params.referenceAnswer] - Ground truth, for context recall
73
+ * (and semantic similarity when an embedder is given)
74
+ * @param {Object} [params.embedder] - With a referenceAnswer, adds
75
+ * `evaluation.scores.semanticSimilarity` (computeSemanticSimilarity)
76
+ * @param {boolean} [params.evaluate=true] - Also compute evaluation scores
77
+ * @returns {Promise<AnswerCheck>}
78
+ */
79
+ export function checkAnswer({ answer, sources, question, referenceAnswer, embedder, evaluate, }?: {
80
+ answer: string;
81
+ sources: Array<string | any>;
82
+ question?: string;
83
+ referenceAnswer?: string;
84
+ embedder?: any;
85
+ evaluate?: boolean;
86
+ }): Promise<AnswerCheck>;
87
+ /**
88
+ * Normalize sources to `{ id, content, metadata }`.
89
+ *
90
+ * Accepts strings, `{ id, content }` (this library), LangChain Documents
91
+ * (`{ pageContent, metadata }`), LlamaIndex nodes (`{ text }`) and
92
+ * `NodeWithScore` (`{ node, score }`).
93
+ *
94
+ * @param {Array<string|Object>} sources
95
+ * @returns {Array<{id: string, content: string, metadata: Object}>}
96
+ */
97
+ export function normalizeSources(sources: Array<string | any>): Array<{
98
+ id: string;
99
+ content: string;
100
+ metadata: any;
101
+ }>;
@@ -17,6 +17,18 @@ export class FileVectorStore {
17
17
  });
18
18
  path: string;
19
19
  _index: any;
20
+ _loadedDigest: string;
21
+ _cacheId: string;
22
+ _revision: number;
23
+ /**
24
+ * Cache identity for createRagPipeline({ cache }): a digest of the index
25
+ * as loaded, so processes reading the same unchanged index share cached
26
+ * results; after this instance changes the index the key also carries the
27
+ * instance and a revision.
28
+ * @param {'retriever'} role
29
+ * @returns {string|null}
30
+ */
31
+ getCacheKey(role: "retriever"): string | null;
20
32
  _emptyIndex(): {
21
33
  version: number;
22
34
  embedder: any;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Deterministic embedder: hashed bag of words, L2-normalised. Texts that
3
+ * share words get similar vectors, so retrieval ranks sensibly in tests.
4
+ */
5
+ export class HashEmbedder {
6
+ /**
7
+ * @param {Object} [options]
8
+ * @param {number} [options.dimensions=256] - Vector length
9
+ */
10
+ constructor({ dimensions }?: {
11
+ dimensions?: number;
12
+ });
13
+ dimensions: number;
14
+ metadata: {
15
+ name: string;
16
+ version: string;
17
+ type: string;
18
+ };
19
+ /**
20
+ * Cache identity for createRagPipeline({ cache }).
21
+ * @param {string} role
22
+ * @returns {string|null}
23
+ */
24
+ getCacheKey(role: string): string | null;
25
+ /** @private */
26
+ private _vector;
27
+ /**
28
+ * @param {string} text
29
+ * @param {{ signal?: AbortSignal }} [options]
30
+ * @returns {Promise<number[]>}
31
+ */
32
+ embed(text: string, { signal }?: {
33
+ signal?: AbortSignal;
34
+ }): Promise<number[]>;
35
+ /**
36
+ * Same as embed().
37
+ * @param {string} text
38
+ * @param {{ signal?: AbortSignal }} [options]
39
+ * @returns {Promise<number[]>}
40
+ */
41
+ embedQuery(text: string, options?: {
42
+ signal?: AbortSignal;
43
+ }): Promise<number[]>;
44
+ /**
45
+ * @param {string[]} texts
46
+ * @param {{ signal?: AbortSignal }} [options]
47
+ * @returns {Promise<number[][]>}
48
+ */
49
+ embedBatch(texts: string[], { signal }?: {
50
+ signal?: AbortSignal;
51
+ }): Promise<number[][]>;
52
+ }
53
+ /**
54
+ * LLM stand-in. Answers with `answer` (a string, or a function of the query
55
+ * and documents), or else with the top document's content. Every call is
56
+ * recorded in `calls`, so tests can assert on what the pipeline sent.
57
+ */
58
+ export class EchoLLM {
59
+ /**
60
+ * @param {Object} [options]
61
+ * @param {string|function(string, Array<Object>): string} [options.answer]
62
+ * @param {string} [options.fallback='No information found.'] - Answer when
63
+ * there are no documents and no `answer`
64
+ */
65
+ constructor({ answer, fallback }?: {
66
+ answer?: string | ((arg0: string, arg1: Array<any>) => string);
67
+ fallback?: string;
68
+ });
69
+ answer: string | ((arg0: string, arg1: Array<any>) => string);
70
+ fallback: string;
71
+ /** @type {Array<{query: string, docs: Array<Object>, options: Object}>} */
72
+ calls: Array<{
73
+ query: string;
74
+ docs: Array<any>;
75
+ options: any;
76
+ }>;
77
+ metadata: {
78
+ name: string;
79
+ version: string;
80
+ type: string;
81
+ };
82
+ /**
83
+ * Cache identity for createRagPipeline({ cache }); none when `answer` is a
84
+ * function, since its output cannot be identified.
85
+ * @param {string} role
86
+ * @returns {string|null}
87
+ */
88
+ getCacheKey(role: string): string | null;
89
+ /** @private */
90
+ private _answer;
91
+ /**
92
+ * @param {string} query
93
+ * @param {Array<Object>} [docs]
94
+ * @param {{ signal?: AbortSignal }} [options]
95
+ * @returns {Promise<string>}
96
+ */
97
+ generate(query: string, docs?: Array<any>, options?: {
98
+ signal?: AbortSignal;
99
+ }): Promise<string>;
100
+ /**
101
+ * Streams the same answer word by word, then a done chunk.
102
+ * @param {string} query
103
+ * @param {Array<Object>} [docs]
104
+ * @param {{ signal?: AbortSignal }} [options]
105
+ * @yields {{ token: string, done: boolean }}
106
+ */
107
+ generateStream(query: string, docs?: Array<any>, options?: {
108
+ signal?: AbortSignal;
109
+ }): AsyncGenerator<{
110
+ token: string;
111
+ done: boolean;
112
+ }, void, unknown>;
113
+ }
114
+ /** Short documents for tests and examples. */
115
+ export const fixtures: Readonly<{
116
+ documents: readonly (Readonly<{
117
+ id: "vacation";
118
+ content: "Full-time employees receive twenty days of paid vacation per year. Unused days carry over to March.";
119
+ metadata: Readonly<{
120
+ source: "vacation.md";
121
+ }>;
122
+ }> | Readonly<{
123
+ id: "remote-work";
124
+ content: "Employees may work remotely up to three days per week with manager approval.";
125
+ metadata: Readonly<{
126
+ source: "remote-work.md";
127
+ }>;
128
+ }> | Readonly<{
129
+ id: "expenses";
130
+ content: "Submit receipts within thirty days. Travel above 500 EUR needs pre-approval from finance.";
131
+ metadata: Readonly<{
132
+ source: "expenses.md";
133
+ }>;
134
+ }>)[];
135
+ }>;
136
+ /**
137
+ * A pipeline with HashEmbedder, a MemoryRetriever already holding
138
+ * `documents`, and EchoLLM (or the llm you pass).
139
+ *
140
+ * @example
141
+ * const { createTestPipeline } = require('@devilsdev/rag-pipeline-utils/testing');
142
+ * const { pipeline, llm } = await createTestPipeline();
143
+ * const result = await pipeline.run({ query: 'How many vacation days?' });
144
+ * // result.results[0].id === 'vacation'; llm.calls.length === 1
145
+ *
146
+ * @param {Object} [options]
147
+ * @param {Array<{id: string, content: string, metadata?: Object}>} [options.documents=fixtures.documents]
148
+ * @param {Object} [options.embedder] - Defaults to a new HashEmbedder
149
+ * @param {Object|null} [options.llm] - Defaults to a new EchoLLM; null for retrieval only
150
+ * @param {Object} [options.pipeline] - Extra createRagPipeline options (e.g. reranker)
151
+ * @returns {Promise<{ pipeline: Object, embedder: Object, retriever: MemoryRetriever, llm: Object|null }>}
152
+ */
153
+ export function createTestPipeline({ documents, embedder, llm, pipeline: pipelineOptions, }?: {
154
+ documents?: Array<{
155
+ id: string;
156
+ content: string;
157
+ metadata?: any;
158
+ }>;
159
+ embedder?: any;
160
+ llm?: any | null;
161
+ pipeline?: any;
162
+ }): Promise<{
163
+ pipeline: any;
164
+ embedder: any;
165
+ retriever: MemoryRetriever;
166
+ llm: any | null;
167
+ }>;
168
+ import { MemoryRetriever } from "./connectors/memory-retriever";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devilsdev/rag-pipeline-utils",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Composable retrieval-augmented generation (RAG) for Node.js: grounded citations, evaluation, guardrails, hybrid retrieval and a CLI.",
5
5
  "main": "dist/index.cjs",
6
6
  "type": "commonjs",
@@ -9,6 +9,9 @@
9
9
  "*": {
10
10
  "experimental": [
11
11
  "dist/experimental.d.ts"
12
+ ],
13
+ "testing": [
14
+ "dist/testing.d.ts"
12
15
  ]
13
16
  }
14
17
  },
@@ -23,6 +26,11 @@
23
26
  "require": "./dist/experimental.cjs",
24
27
  "import": "./dist/experimental.mjs"
25
28
  },
29
+ "./testing": {
30
+ "types": "./dist/testing.d.ts",
31
+ "require": "./dist/testing.cjs",
32
+ "import": "./dist/testing.mjs"
33
+ },
26
34
  "./cli": "./bin/cli.js"
27
35
  },
28
36
  "bin": {
@@ -59,16 +67,16 @@
59
67
  "lint-staged": "lint-staged",
60
68
  "postinstall": "husky install",
61
69
  "verify:mocks": "node scripts/verify-fixture-mocks.js",
62
- "build:docs": "npx docusaurus build docs-site",
70
+ "build:docs": "npm run docs:build",
63
71
  "ci": "node scripts/ci-runner.js",
64
- "docs:dev": "npx docusaurus start docs-site",
72
+ "docs:dev": "cd docs-site && npm run start",
65
73
  "docs:version": "npx docusaurus docs:version",
66
74
  "watch:lqip": "node docs-site/scripts/watch-images.js",
67
75
  "docs:clear": "docusaurus clear --site-dir docs-site",
68
- "docs:start": "docusaurus start docs-site",
76
+ "docs:start": "cd docs-site && npm run start",
69
77
  "docs:build": "cd docs-site && npm run build",
70
78
  "docs:serve": "docusaurus serve docs-site",
71
- "docs:deploy": "docusaurus deploy docs-site",
79
+ "docs:deploy": "node docs-site/scripts/sync-changelog.js && docusaurus deploy docs-site",
72
80
  "roadmap:close": "node scripts/close-done-roadmap-issues.js",
73
81
  "labels:sync": "node scripts/ensure-roadmap-labels.js",
74
82
  "release:tag": "node scripts/generate-release-note.js v$(node -p \"require('./package.json').version\")",
@@ -176,8 +184,12 @@
176
184
  "devDependencies": {
177
185
  "@babel/core": "^7.26.10",
178
186
  "@babel/preset-env": "^7.26.9",
179
- "@docusaurus/core": "3.10.0",
180
- "@docusaurus/preset-classic": "3.10.0",
187
+ "@docusaurus/core": "3.10.2",
188
+ "@docusaurus/preset-classic": "3.10.2",
189
+ "@docusaurus/theme-mermaid": "3.10.2",
190
+ "@easyops-cn/docusaurus-search-local": "0.55.3",
191
+ "@fontsource/inter": "5.3.0",
192
+ "@fontsource/jetbrains-mono": "5.3.0",
181
193
  "@octokit/rest": "^21.1.1",
182
194
  "@opentelemetry/api": "^1.9.0",
183
195
  "@opentelemetry/auto-instrumentations-node": "^0.52.1",
@@ -192,8 +204,11 @@
192
204
  "@types/jest": "^29.5.14",
193
205
  "@types/node": "22.14.1",
194
206
  "acorn": "8.15.0",
207
+ "ai": "6.0.317",
208
+ "ai-v7": "npm:ai@7.0.156",
195
209
  "babel-jest": "^29.7.0",
196
210
  "cross-env": "7.0.3",
211
+ "docusaurus-plugin-llms": "0.6.1",
197
212
  "esbuild": "^0.25.2",
198
213
  "eslint": "^8.57.1",
199
214
  "eslint-plugin-storybook": "^0.12.0",
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Adapter for the Vercel AI SDK (`ai` package): use an AI SDK embedding
5
+ * model and language model as this library's `embedder` and `llm`.
6
+ *
7
+ * The AI SDK is not a dependency. You pass in the functions you already
8
+ * import from it, so any AI SDK version from 5 on works, from ESM or
9
+ * CommonJS, and this package keeps supporting Node 18 (AI SDK 7 needs Node
10
+ * 22 and is ESM-only).
11
+ */
12
+
13
+ const { buildRagPrompt } = require('../connectors/rag-prompt');
14
+ const { componentCacheKey } = require('../cache/component-keys');
15
+
16
+ /** Identity of an AI SDK model object (provider and model id). */
17
+ const modelId = (model) =>
18
+ `${model.provider || 'unknown'}:${model.modelId || model.modelName || 'unknown'}`;
19
+
20
+ /**
21
+ * @typedef {Object} AiSdkFunctions
22
+ * @property {Function} [embed] - `embed` from 'ai' (needed for an embedder)
23
+ * @property {Function} [embedMany] - `embedMany` from 'ai' (batch embedding)
24
+ * @property {Function} [generateText] - `generateText` from 'ai' (needed for an llm)
25
+ * @property {Function} [streamText] - `streamText` from 'ai' (streaming)
26
+ */
27
+
28
+ function requireFunction(ai, name, purpose) {
29
+ if (typeof ai[name] !== 'function') {
30
+ throw new Error(
31
+ `createAiSdkAdapter: ${purpose} needs ai.${name}. Pass the AI SDK functions, e.g. import { ${name} } from "ai".`,
32
+ );
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Options for every generateText / streamText call. The RAG system prompt
38
+ * (retrieved text is untrusted reference data, not instructions) is passed
39
+ * as both `instructions` (AI SDK 7) and `system` (AI SDK 5 and 6; 7 reads
40
+ * `instructions ?? system`), so it reaches the model on every version.
41
+ */
42
+ function textCallOptions(model, query, docs, settings, signal, systemPrompt) {
43
+ const { system, user } = buildRagPrompt(query, docs, { systemPrompt });
44
+ return {
45
+ ...settings,
46
+ model,
47
+ ...(system && { system, instructions: system }),
48
+ prompt: user,
49
+ ...(signal && { abortSignal: signal }),
50
+ };
51
+ }
52
+
53
+ /** Per-call settings the pipeline may pass through to the model. */
54
+ const CALL_SETTINGS = [
55
+ 'temperature',
56
+ 'maxOutputTokens',
57
+ 'topP',
58
+ 'topK',
59
+ 'presencePenalty',
60
+ 'frequencyPenalty',
61
+ 'seed',
62
+ 'stopSequences',
63
+ 'maxRetries',
64
+ ];
65
+
66
+ function pickSettings(options) {
67
+ const out = {};
68
+ for (const key of CALL_SETTINGS) {
69
+ if (options[key] !== undefined) out[key] = options[key];
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Wrap AI SDK models as an embedder and an llm for createRagPipeline().
76
+ *
77
+ * @example
78
+ * import { embed, embedMany, generateText, streamText } from "ai";
79
+ * import { openai } from "@ai-sdk/openai";
80
+ * import { createAiSdkAdapter, createRagPipeline } from "@devilsdev/rag-pipeline-utils";
81
+ *
82
+ * const { embedder, llm } = createAiSdkAdapter({
83
+ * ai: { embed, embedMany, generateText, streamText },
84
+ * embeddingModel: openai.embedding("text-embedding-3-small"),
85
+ * languageModel: openai("gpt-4o-mini"),
86
+ * });
87
+ * const pipeline = createRagPipeline({ embedder, retriever, llm });
88
+ *
89
+ * @param {Object} options
90
+ * @param {AiSdkFunctions} options.ai - Functions imported from 'ai'
91
+ * @param {Object} [options.embeddingModel] - AI SDK embedding model
92
+ * @param {Object} [options.languageModel] - AI SDK language model
93
+ * @param {Object} [options.settings] - Defaults for every text call, e.g.
94
+ * `{ temperature: 0, maxOutputTokens: 512 }`
95
+ * @param {string} [options.systemPrompt] - Replaces the built-in RAG system
96
+ * prompt
97
+ * @returns {{ embedder: Object|null, llm: Object|null }}
98
+ */
99
+ function createAiSdkAdapter(options = {}) {
100
+ const {
101
+ ai,
102
+ embeddingModel,
103
+ languageModel,
104
+ settings = {},
105
+ systemPrompt,
106
+ } = options;
107
+ if (!ai || typeof ai !== 'object') {
108
+ throw new Error(
109
+ 'createAiSdkAdapter: pass `ai`, the AI SDK functions, e.g. { embed, embedMany, generateText, streamText } from "ai".',
110
+ );
111
+ }
112
+ if (!embeddingModel && !languageModel) {
113
+ throw new Error(
114
+ 'createAiSdkAdapter: pass an embeddingModel, a languageModel, or both.',
115
+ );
116
+ }
117
+
118
+ let embedder = null;
119
+ if (embeddingModel) {
120
+ requireFunction(ai, 'embed', 'an embeddingModel');
121
+ const embedOne = async (text, { signal } = {}) => {
122
+ const { embedding } = await ai.embed({
123
+ model: embeddingModel,
124
+ value: String(text),
125
+ ...(signal && { abortSignal: signal }),
126
+ });
127
+ return embedding;
128
+ };
129
+ embedder = {
130
+ metadata: { name: 'ai-sdk-embedder', version: '1.0.0', type: 'embedder' },
131
+ getCacheKey: (role) =>
132
+ role === 'embedder'
133
+ ? componentCacheKey('ai-sdk-embed', {
134
+ model: modelId(embeddingModel),
135
+ })
136
+ : null,
137
+ embed: embedOne,
138
+ embedQuery: embedOne,
139
+ /** One vector per text; uses embedMany when it was passed in. */
140
+ async embedBatch(texts, { signal } = {}) {
141
+ if (typeof ai.embedMany !== 'function') {
142
+ const vectors = [];
143
+ for (const text of texts)
144
+ vectors.push(await embedOne(text, { signal }));
145
+ return vectors;
146
+ }
147
+ const { embeddings } = await ai.embedMany({
148
+ model: embeddingModel,
149
+ values: texts.map(String),
150
+ ...(signal && { abortSignal: signal }),
151
+ });
152
+ return embeddings;
153
+ },
154
+ };
155
+ }
156
+
157
+ let llm = null;
158
+ if (languageModel) {
159
+ requireFunction(ai, 'generateText', 'a languageModel');
160
+ llm = {
161
+ metadata: { name: 'ai-sdk-llm', version: '1.0.0', type: 'llm' },
162
+ // The AI SDK leaves an unset temperature to the provider, whose default
163
+ // is usually 1, so unset counts as non-deterministic: generation
164
+ // caching needs `settings.temperature: 0`.
165
+ config: { temperature: settings.temperature ?? 1 },
166
+ getCacheKey: (role) =>
167
+ role === 'llm'
168
+ ? componentCacheKey('ai-sdk', {
169
+ model: modelId(languageModel),
170
+ settings: JSON.stringify(settings),
171
+ systemPrompt,
172
+ })
173
+ : null,
174
+ async generate(query, docs = [], callOptions = {}) {
175
+ const { text } = await ai.generateText(
176
+ textCallOptions(
177
+ languageModel,
178
+ query,
179
+ docs,
180
+ { ...settings, ...pickSettings(callOptions) },
181
+ callOptions.signal,
182
+ systemPrompt,
183
+ ),
184
+ );
185
+ return text;
186
+ },
187
+ };
188
+ if (typeof ai.streamText === 'function') {
189
+ llm.generateStream = async function* generateStream(
190
+ query,
191
+ docs = [],
192
+ callOptions = {},
193
+ ) {
194
+ const result = ai.streamText(
195
+ textCallOptions(
196
+ languageModel,
197
+ query,
198
+ docs,
199
+ { ...settings, ...pickSettings(callOptions) },
200
+ callOptions.signal,
201
+ systemPrompt,
202
+ ),
203
+ );
204
+ for await (const token of result.textStream) {
205
+ yield { token, done: false };
206
+ }
207
+ yield { token: '', done: true };
208
+ };
209
+ }
210
+ }
211
+
212
+ return { embedder, llm };
213
+ }
214
+
215
+ module.exports = { createAiSdkAdapter };
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Builds the cache identity a built-in component reports through
5
+ * getCacheKey(role): every setting that changes its output, so two
6
+ * configurations never share cached results.
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+
11
+ const digest = (value) =>
12
+ crypto
13
+ .createHash('sha256')
14
+ .update(String(value ?? ''))
15
+ .digest('hex')
16
+ .slice(0, 16);
17
+
18
+ /**
19
+ * @param {string} kind - e.g. 'openai'
20
+ * @param {Object} fields - Settings that change the output
21
+ * @returns {string}
22
+ */
23
+ function componentCacheKey(kind, fields) {
24
+ const parts = Object.keys(fields)
25
+ .sort()
26
+ .map((name) => {
27
+ const value = fields[name];
28
+ // Long free text (system prompts) is hashed to keep keys short.
29
+ const text =
30
+ typeof value === 'string' && value.length > 64
31
+ ? `#${digest(value)}`
32
+ : value;
33
+ return `${name}=${text === undefined ? '' : text}`;
34
+ });
35
+ return `${kind}|${parts.join('|')}`;
36
+ }
37
+
38
+ module.exports = { componentCacheKey, digest };