pi-vault-mind 0.14.2 → 0.16.3

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 (164) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/README.md +5 -5
  3. package/agents/broadcaster.agent.md +2 -2
  4. package/agents/heavy-lifter.agent.md +1 -1
  5. package/agents/main.agent.md +29 -0
  6. package/agents/manager.agent.md +1 -1
  7. package/agents/miner.agent.md +1 -1
  8. package/dist/packages/obsidian/src/client.js +331 -0
  9. package/dist/packages/obsidian/src/types.js +1 -0
  10. package/dist/src/activity.d.ts +49 -0
  11. package/dist/src/activity.js +175 -0
  12. package/dist/src/agent-bus/Agent.d.ts +9 -1
  13. package/dist/src/agent-bus/Agent.js +18 -2
  14. package/dist/src/agent-bus/index.d.ts +5 -5
  15. package/dist/src/agent-bus/index.js +1 -1
  16. package/dist/src/agent-queue.js +12 -3
  17. package/dist/src/agents/BroadcasterAgent.d.ts +1 -1
  18. package/dist/src/agents/BroadcasterAgent.js +2 -2
  19. package/dist/src/agents/HeavyLifterAgent.d.ts +1 -1
  20. package/dist/src/agents/HeavyLifterAgent.js +2 -2
  21. package/dist/src/agents/ManagerAgent.js +1 -1
  22. package/dist/src/agents/MinerAgent.d.ts +1 -1
  23. package/dist/src/agents/MinerAgent.js +2 -2
  24. package/dist/src/agents/index.d.ts +2 -2
  25. package/dist/src/agents/index.js +2 -2
  26. package/dist/src/auth.d.ts +17 -15
  27. package/dist/src/auth.js +26 -37
  28. package/dist/src/autosync.js +2 -2
  29. package/dist/src/commands.d.ts +2 -19
  30. package/dist/src/commands.js +301 -231
  31. package/dist/src/config-keys.d.ts +16 -0
  32. package/dist/src/config-keys.js +45 -0
  33. package/dist/src/discover-schema.js +4 -4
  34. package/dist/src/edits.d.ts +21 -0
  35. package/dist/src/edits.js +75 -0
  36. package/dist/src/embedding-probe.d.ts +34 -0
  37. package/dist/src/embedding-probe.js +142 -0
  38. package/dist/src/embedding-providers.d.ts +133 -0
  39. package/dist/src/embedding-providers.js +173 -0
  40. package/dist/src/embedding-secrets.d.ts +38 -0
  41. package/dist/src/embedding-secrets.js +291 -0
  42. package/dist/src/engine.d.ts +6 -0
  43. package/dist/src/engine.js +78 -6
  44. package/dist/src/events.js +7 -10
  45. package/dist/src/extension-packages.d.ts +9 -0
  46. package/dist/src/extension-packages.js +14 -0
  47. package/dist/src/git.d.ts +19 -0
  48. package/dist/src/git.js +78 -0
  49. package/dist/src/graph.d.ts +2 -1
  50. package/dist/src/graph.js +47 -13
  51. package/dist/src/identities-config.d.ts +6 -0
  52. package/dist/src/identities-config.js +39 -0
  53. package/dist/src/identity-injector.js +1 -1
  54. package/dist/src/index.js +9 -26
  55. package/dist/src/lance.d.ts +9 -0
  56. package/dist/src/lance.js +143 -203
  57. package/dist/src/modal-client.d.ts +16 -0
  58. package/dist/src/modal-client.js +16 -0
  59. package/dist/src/modal-config.d.ts +37 -14
  60. package/dist/src/modal-config.js +59 -32
  61. package/dist/src/model-router.d.ts +27 -0
  62. package/dist/src/model-router.js +67 -0
  63. package/dist/src/models.d.ts +42 -0
  64. package/dist/src/models.js +78 -0
  65. package/dist/src/personalize.js +17 -3
  66. package/dist/src/scaffold.d.ts +48 -0
  67. package/dist/src/scaffold.js +246 -0
  68. package/dist/src/server.d.ts +47 -4
  69. package/dist/src/server.js +988 -221
  70. package/dist/src/session-search.d.ts +63 -0
  71. package/dist/src/session-search.js +379 -0
  72. package/dist/src/settings-ui.d.ts +2 -2
  73. package/dist/src/settings-ui.js +76 -135
  74. package/dist/src/sync.d.ts +3 -3
  75. package/dist/src/sync.js +19 -15
  76. package/dist/src/tool-catalog.d.ts +18 -0
  77. package/dist/src/tool-catalog.js +232 -0
  78. package/dist/src/tools/marksman.js +1 -1
  79. package/dist/src/tools.js +111 -65
  80. package/dist/src/types.d.ts +127 -71
  81. package/dist/src/utils.d.ts +57 -6
  82. package/dist/src/utils.js +114 -28
  83. package/dist/src/vault-tools.d.ts +34 -0
  84. package/dist/src/vault-tools.js +99 -0
  85. package/dist/src/vault-writer.js +4 -1
  86. package/dist/src/vm-handlers.js +140 -43
  87. package/dist/src/watcher.js +9 -9
  88. package/dist/test/activity-tool.test.js +54 -0
  89. package/dist/test/agent-bus/Agent.test.js +174 -0
  90. package/dist/test/agent-bus/AgentLoader.test.js +180 -0
  91. package/dist/test/agent-bus/AgentRegistry.test.js +86 -0
  92. package/dist/test/agent-bus/LLMProvider.test.js +162 -0
  93. package/dist/test/agent-bus/TaskQueue.test.js +256 -0
  94. package/dist/test/agent-dispatch-context.test.js +73 -0
  95. package/dist/test/agent-queue.test.js +1 -1
  96. package/dist/test/agents/Agents.test.js +282 -0
  97. package/dist/test/auth.test.js +57 -56
  98. package/dist/test/bridge-identity.test.js +34 -3
  99. package/dist/test/classify-search-mode.test.js +51 -0
  100. package/dist/test/commands.test.js +410 -0
  101. package/dist/test/config-merge.test.js +98 -0
  102. package/dist/test/context-capture.test.js +56 -0
  103. package/dist/test/discover-schema.test.js +8 -5
  104. package/dist/test/dispatch.test.js +45 -91
  105. package/dist/test/edits.test.js +144 -0
  106. package/dist/test/embedding-probe.test.js +424 -0
  107. package/dist/test/embedding-providers.test.js +157 -0
  108. package/dist/test/embedding-secrets.test.js +210 -0
  109. package/dist/test/engine-identity.test.js +1 -1
  110. package/dist/test/events.test.js +675 -0
  111. package/dist/test/git-bridge.test.js +160 -0
  112. package/dist/test/graph.test.js +464 -0
  113. package/dist/test/helpers/server.js +81 -0
  114. package/dist/test/identity-injector.test.js +197 -0
  115. package/dist/test/index.test.js +5 -7
  116. package/dist/test/lance-modal.test.js +67 -2
  117. package/dist/test/lance.test.js +429 -0
  118. package/dist/test/modal-client.test.js +1 -1
  119. package/dist/test/modal-config.test.js +92 -79
  120. package/dist/test/model-readiness.test.js +122 -0
  121. package/dist/test/model-router.test.js +99 -0
  122. package/dist/test/obsidian-client.test.js +42 -0
  123. package/dist/test/personalize.test.js +1 -1
  124. package/dist/test/rest-models.test.js +153 -0
  125. package/dist/test/rest-queue.test.js +135 -87
  126. package/dist/test/rest-setup.test.js +625 -115
  127. package/dist/test/rest-vm.test.js +355 -94
  128. package/dist/test/server-cors.test.js +48 -0
  129. package/dist/test/server-ws.test.js +61 -34
  130. package/dist/test/session-crud.test.js +323 -0
  131. package/dist/test/session-search.test.js +139 -0
  132. package/dist/test/settings-ui.test.js +113 -4
  133. package/dist/test/sync.test.js +12 -12
  134. package/dist/test/tombstone.test.js +8 -10
  135. package/dist/test/tool-catalog.test.js +86 -0
  136. package/dist/test/utils.test.js +588 -0
  137. package/dist/test/vault-tools.test.js +103 -12
  138. package/dist/test/vault-writer.test.js +371 -0
  139. package/dist/test/vm-handlers.test.js +620 -0
  140. package/extension-packages.json +9 -0
  141. package/package.json +107 -90
  142. package/pi-vault-mind.config.example.json +24 -2
  143. package/scripts/check-deps.sh +35 -32
  144. package/scripts/cli-e2e.mjs +215 -0
  145. package/scripts/configuration-e2e.mjs +783 -0
  146. package/scripts/e2e-commands.mjs +391 -0
  147. package/scripts/e2e-ws.mjs +3 -2
  148. package/scripts/fetch-modal-token.sh +4 -3
  149. package/scripts/generate-config-keys-json.mjs +45 -0
  150. package/scripts/generate-extension-packages-json.mjs +55 -0
  151. package/scripts/pi-session-query +0 -0
  152. package/scripts/publish-obsidian-plugin.sh +0 -0
  153. package/scripts/reset-test-vault.sh +2 -2
  154. package/scripts/setup-test-config.sh +0 -0
  155. package/scripts/setup-vault-pi.sh +48 -9
  156. package/scripts/test-slash-commands.mjs +2 -2
  157. package/scripts/vault-pi.sh +12 -3
  158. package/skills/github-release-notes-file/SKILL.md +42 -0
  159. package/skills/obsidian-guided-test-session/SKILL.md +110 -0
  160. package/skills/obsidian-interactive-test/SKILL.md +88 -0
  161. package/skills/obsidian-plugin-publish/SKILL.md +58 -0
  162. package/skills/pi-vault-mind-release/SKILL.md +141 -0
  163. package/skills/pre-test-release/SKILL.md +116 -0
  164. package/dist/test/auto-indexer.test.js +0 -2
@@ -0,0 +1,424 @@
1
+ import { strict as assert } from "node:assert";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
6
+ import { probeEmbeddingEndpoint } from "../src/embedding-probe.js";
7
+ const PROVIDER_ENV_KEYS = [
8
+ "PVM_API_TOKEN",
9
+ "PVM_LOCAL_EMBEDDING_API_KEY",
10
+ "PVM_REMOTE_EMBEDDING_API_KEY",
11
+ "PVM_MODAL_READ_TOKEN",
12
+ "PVM_MODAL_WRITE_TOKEN",
13
+ ];
14
+ const configWith = (embedding = {}) => ({
15
+ dataDir: ".lancedb",
16
+ embedding,
17
+ });
18
+ const jsonResponse = (body, status = 200) => new Response(JSON.stringify(body), {
19
+ status,
20
+ headers: { "Content-Type": "application/json" },
21
+ });
22
+ const createFetch = (handler) => {
23
+ const calls = [];
24
+ const injectedFetch = async (input, init) => {
25
+ const request = input instanceof Request ? input : null;
26
+ const headers = new Headers(request?.headers);
27
+ new Headers(init?.headers).forEach((value, key) => {
28
+ headers.set(key, value);
29
+ });
30
+ const call = {
31
+ url: request?.url ?? String(input),
32
+ method: init?.method ?? request?.method ?? "GET",
33
+ headers,
34
+ };
35
+ calls.push(call);
36
+ return handler(call, calls.length - 1);
37
+ };
38
+ return { fetch: injectedFetch, calls };
39
+ };
40
+ const logValue = (value) => {
41
+ if (value instanceof Error)
42
+ return `${value.name}: ${value.message}`;
43
+ if (typeof value === "string")
44
+ return value;
45
+ try {
46
+ return JSON.stringify(value);
47
+ }
48
+ catch {
49
+ return String(value);
50
+ }
51
+ };
52
+ const captureConsole = () => {
53
+ const entries = [];
54
+ const capture = (...args) => entries.push(args.map(logValue).join(" "));
55
+ mock.method(console, "debug", capture);
56
+ mock.method(console, "error", capture);
57
+ mock.method(console, "info", capture);
58
+ mock.method(console, "log", capture);
59
+ mock.method(console, "warn", capture);
60
+ return entries;
61
+ };
62
+ describe("probeEmbeddingEndpoint", () => {
63
+ let originalCwd;
64
+ let scratchDir;
65
+ let savedEnvironment;
66
+ beforeEach(() => {
67
+ originalCwd = process.cwd();
68
+ scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "pvm-embedding-probe-"));
69
+ process.chdir(scratchDir);
70
+ savedEnvironment = {};
71
+ for (const key of PROVIDER_ENV_KEYS) {
72
+ savedEnvironment[key] = process.env[key];
73
+ delete process.env[key];
74
+ }
75
+ });
76
+ afterEach(() => {
77
+ mock.restoreAll();
78
+ process.chdir(originalCwd);
79
+ for (const key of PROVIDER_ENV_KEYS) {
80
+ const saved = savedEnvironment[key];
81
+ if (saved === undefined)
82
+ delete process.env[key];
83
+ else
84
+ process.env[key] = saved;
85
+ }
86
+ fs.rmSync(scratchDir, { recursive: true, force: true });
87
+ });
88
+ describe("provider and URL normalization", () => {
89
+ it("normalizes an Ollama base URL and returns sorted models with injected-clock latency", async () => {
90
+ const { fetch, calls } = createFetch(() => jsonResponse({
91
+ models: [{ name: "zeta-embed:latest" }, { name: "alpha-embed:latest" }],
92
+ }));
93
+ const clock = [400, 437];
94
+ const result = await probeEmbeddingEndpoint({
95
+ target: "local",
96
+ url: "http://127.0.0.1:11434/v1/embeddings/",
97
+ model: "zeta-embed:latest",
98
+ }, { fetch, now: () => clock.shift() ?? 437, config: configWith() });
99
+ assert.deepEqual(calls.map(({ url, method }) => ({ url, method })), [{ url: "http://127.0.0.1:11434/api/tags", method: "GET" }]);
100
+ assert.deepEqual(result, {
101
+ ok: true,
102
+ target: "local",
103
+ endpoint: "http://127.0.0.1:11434",
104
+ models: [
105
+ { id: "alpha-embed:latest", name: "alpha-embed:latest", dim: null },
106
+ { id: "zeta-embed:latest", name: "zeta-embed:latest", dim: null },
107
+ ],
108
+ selectedModelAvailable: true,
109
+ latencyMs: 37,
110
+ error: null,
111
+ });
112
+ });
113
+ it("falls back from Ollama tags to an OpenAI-compatible model catalog", async () => {
114
+ const { fetch, calls } = createFetch(({ url }) => {
115
+ if (url.endsWith("/api/tags"))
116
+ return jsonResponse({ error: "not Ollama" }, 404);
117
+ return jsonResponse({ data: [{ id: "text-embed-z" }, { id: "text-embed-a" }] });
118
+ });
119
+ const result = await probeEmbeddingEndpoint({
120
+ target: "local",
121
+ url: "https://embeddings.example.test/v1/",
122
+ model: "not-installed",
123
+ }, { fetch, now: () => 10, config: configWith() });
124
+ assert.deepEqual(calls.map(({ url }) => url), ["https://embeddings.example.test/api/tags", "https://embeddings.example.test/v1/models"]);
125
+ assert.deepEqual(result.models, [
126
+ { id: "text-embed-a", name: "text-embed-a", dim: null },
127
+ { id: "text-embed-z", name: "text-embed-z", dim: null },
128
+ ]);
129
+ assert.equal(result.endpoint, "https://embeddings.example.test");
130
+ assert.equal(result.selectedModelAvailable, false);
131
+ assert.equal(result.ok, true);
132
+ assert.equal(result.error, null);
133
+ });
134
+ it("normalizes Modal models, preserves native dimensions, and sorts by ID", async () => {
135
+ const { fetch, calls } = createFetch(() => jsonResponse({
136
+ default: "embeddinggemma",
137
+ models: [
138
+ { key: "snowflake-arctic-embed", native_dim: 1024 },
139
+ { key: "embeddinggemma", native_dim: 768 },
140
+ ],
141
+ }));
142
+ const result = await probeEmbeddingEndpoint({
143
+ target: "remote",
144
+ url: "https://workspace--vault-mind.modal.run/",
145
+ model: "missing-model",
146
+ }, { fetch, now: () => 25, config: configWith() });
147
+ assert.deepEqual(calls.map(({ url }) => url), ["https://workspace--vault-mind.modal.run/models"]);
148
+ assert.deepEqual(result, {
149
+ ok: true,
150
+ target: "remote",
151
+ endpoint: "https://workspace--vault-mind.modal.run",
152
+ models: [
153
+ { id: "embeddinggemma", name: "embeddinggemma", dim: 768 },
154
+ {
155
+ id: "snowflake-arctic-embed",
156
+ name: "snowflake-arctic-embed",
157
+ dim: 1024,
158
+ },
159
+ ],
160
+ selectedModelAvailable: false,
161
+ latencyMs: 0,
162
+ error: null,
163
+ });
164
+ });
165
+ it("falls back from Modal models to OpenAI models after stripping /v1/embeddings", async () => {
166
+ const { fetch, calls } = createFetch(({ url }) => {
167
+ if (url.endsWith("/models") && !url.endsWith("/v1/models")) {
168
+ return jsonResponse({ detail: "not Modal" }, 404);
169
+ }
170
+ return jsonResponse({ data: [{ id: "remote-z" }, { id: "remote-a" }] });
171
+ });
172
+ const result = await probeEmbeddingEndpoint({
173
+ target: "remote",
174
+ url: "https://api.example.test/v1/embeddings",
175
+ }, { fetch, now: () => 90, config: configWith() });
176
+ assert.deepEqual(calls.map(({ url }) => url), ["https://api.example.test/models", "https://api.example.test/v1/models"]);
177
+ assert.deepEqual(result.models, [
178
+ { id: "remote-a", name: "remote-a", dim: null },
179
+ { id: "remote-z", name: "remote-z", dim: null },
180
+ ]);
181
+ assert.equal(result.endpoint, "https://api.example.test");
182
+ assert.equal(result.selectedModelAvailable, null);
183
+ assert.equal(result.ok, true);
184
+ assert.equal(result.error, null);
185
+ });
186
+ });
187
+ describe("credential isolation", () => {
188
+ const transientCases = [
189
+ {
190
+ name: "local uses only the transient apiKey",
191
+ request: {
192
+ target: "local",
193
+ url: "http://127.0.0.1:11434",
194
+ transientSecrets: {
195
+ apiKey: "LOCAL_TRANSIENT_SENTINEL",
196
+ readApiKey: "REMOTE_READ_TRANSIENT_SENTINEL",
197
+ writeApiKey: "REMOTE_WRITE_TRANSIENT_SENTINEL",
198
+ },
199
+ },
200
+ config: configWith({ apiKey: "LOCAL_CONFIG_SENTINEL" }),
201
+ expectedAuthorization: "Bearer LOCAL_TRANSIENT_SENTINEL",
202
+ },
203
+ {
204
+ name: "remote prefers the transient readApiKey over unscoped and write keys",
205
+ request: {
206
+ target: "remote",
207
+ url: "https://probe.example.test",
208
+ transientSecrets: {
209
+ apiKey: "REMOTE_TRANSIENT_SENTINEL",
210
+ readApiKey: "REMOTE_READ_TRANSIENT_SENTINEL",
211
+ writeApiKey: "REMOTE_WRITE_TRANSIENT_SENTINEL",
212
+ },
213
+ },
214
+ config: configWith({
215
+ remoteApiKey: "REMOTE_CONFIG_SENTINEL",
216
+ remoteReadApiKey: "REMOTE_READ_CONFIG_SENTINEL",
217
+ }),
218
+ expectedAuthorization: "Bearer REMOTE_READ_TRANSIENT_SENTINEL",
219
+ },
220
+ {
221
+ name: "remote falls back to the transient unscoped apiKey when readApiKey is absent",
222
+ request: {
223
+ target: "remote",
224
+ url: "https://probe.example.test",
225
+ transientSecrets: {
226
+ apiKey: "REMOTE_TRANSIENT_SENTINEL",
227
+ writeApiKey: "REMOTE_WRITE_TRANSIENT_SENTINEL",
228
+ },
229
+ },
230
+ config: configWith({ remoteReadApiKey: "REMOTE_READ_CONFIG_SENTINEL" }),
231
+ expectedAuthorization: "Bearer REMOTE_TRANSIENT_SENTINEL",
232
+ },
233
+ ];
234
+ for (const testCase of transientCases) {
235
+ it(testCase.name, async () => {
236
+ const { fetch, calls } = createFetch(() => testCase.request.target === "local"
237
+ ? jsonResponse({ models: [{ name: "embeddinggemma" }] })
238
+ : jsonResponse({ models: [{ key: "embeddinggemma", native_dim: 768 }] }));
239
+ const result = await probeEmbeddingEndpoint(testCase.request, {
240
+ fetch,
241
+ now: () => 0,
242
+ config: testCase.config,
243
+ });
244
+ assert.equal(result.ok, true);
245
+ assert.equal(calls[0].headers.get("authorization"), testCase.expectedAuthorization);
246
+ });
247
+ }
248
+ it("uses matching stored credentials without persisting, echoing, or logging any credential", async () => {
249
+ const storeDir = path.join(scratchDir, ".vault-mind");
250
+ const storePath = path.join(storeDir, "vault-mind.env");
251
+ fs.mkdirSync(storeDir, { recursive: true });
252
+ const storedBytes = [
253
+ 'PVM_API_TOKEN="BRIDGE_TOKEN_SENTINEL"',
254
+ 'PVM_LOCAL_EMBEDDING_API_KEY="LOCAL_STORED_SENTINEL"',
255
+ 'PVM_REMOTE_EMBEDDING_API_KEY="REMOTE_STORED_SENTINEL"',
256
+ 'PVM_MODAL_READ_TOKEN="REMOTE_READ_STORED_SENTINEL"',
257
+ 'PVM_MODAL_WRITE_TOKEN="REMOTE_WRITE_STORED_SENTINEL"',
258
+ 'UNRELATED_SETTING="keep-me"',
259
+ "",
260
+ ].join("\n");
261
+ fs.writeFileSync(storePath, storedBytes, "utf-8");
262
+ const consoleEntries = captureConsole();
263
+ const localRequest = {
264
+ target: "local",
265
+ url: "http://127.0.0.1:11434",
266
+ };
267
+ const remoteRequest = {
268
+ target: "remote",
269
+ url: "https://probe.example.test",
270
+ };
271
+ const transientRequest = {
272
+ ...remoteRequest,
273
+ transientSecrets: { readApiKey: "REMOTE_READ_TRANSIENT_SENTINEL" },
274
+ };
275
+ const config = configWith({
276
+ apiKey: "LOCAL_LEGACY_SENTINEL",
277
+ remoteApiKey: "REMOTE_LEGACY_SENTINEL",
278
+ remoteReadApiKey: "REMOTE_READ_LEGACY_SENTINEL",
279
+ });
280
+ const originalRequests = structuredClone({ localRequest, remoteRequest, transientRequest });
281
+ const originalConfig = structuredClone(config);
282
+ const { fetch, calls } = createFetch(({ url }) => url.endsWith("/api/tags")
283
+ ? jsonResponse({ models: [{ name: "local-model" }] })
284
+ : jsonResponse({ models: [{ key: "remote-model", native_dim: 768 }] }));
285
+ const localResult = await probeEmbeddingEndpoint(localRequest, {
286
+ fetch,
287
+ now: () => 1,
288
+ config,
289
+ });
290
+ const remoteResult = await probeEmbeddingEndpoint(remoteRequest, {
291
+ fetch,
292
+ now: () => 1,
293
+ config,
294
+ });
295
+ const transientResult = await probeEmbeddingEndpoint(transientRequest, {
296
+ fetch,
297
+ now: () => 1,
298
+ config,
299
+ });
300
+ assert.deepEqual(calls.map(({ headers }) => headers.get("authorization")), [
301
+ "Bearer LOCAL_STORED_SENTINEL",
302
+ "Bearer REMOTE_READ_STORED_SENTINEL",
303
+ "Bearer REMOTE_READ_TRANSIENT_SENTINEL",
304
+ ]);
305
+ assert.equal(fs.readFileSync(storePath, "utf-8"), storedBytes);
306
+ assert.deepEqual(fs.readdirSync(storeDir), ["vault-mind.env"]);
307
+ assert.deepEqual({ localRequest, remoteRequest, transientRequest }, originalRequests);
308
+ assert.deepEqual(config, originalConfig);
309
+ const observableText = JSON.stringify({
310
+ localResult,
311
+ remoteResult,
312
+ transientResult,
313
+ consoleEntries,
314
+ });
315
+ for (const sentinel of [
316
+ "BRIDGE_TOKEN_SENTINEL",
317
+ "LOCAL_STORED_SENTINEL",
318
+ "REMOTE_STORED_SENTINEL",
319
+ "REMOTE_READ_STORED_SENTINEL",
320
+ "REMOTE_WRITE_STORED_SENTINEL",
321
+ "REMOTE_READ_TRANSIENT_SENTINEL",
322
+ "LOCAL_LEGACY_SENTINEL",
323
+ "REMOTE_LEGACY_SENTINEL",
324
+ "REMOTE_READ_LEGACY_SENTINEL",
325
+ ]) {
326
+ assert.equal(observableText.includes(sentinel), false, `leaked ${sentinel}`);
327
+ }
328
+ });
329
+ });
330
+ describe("validation and normalized failures", () => {
331
+ const invalidCases = [
332
+ {
333
+ name: "rejects an unknown target before fetching",
334
+ request: {
335
+ target: "other",
336
+ url: "https://probe.example.test",
337
+ },
338
+ message: /target/i,
339
+ },
340
+ {
341
+ name: "rejects a malformed URL before fetching",
342
+ request: { target: "local", url: "not a URL" },
343
+ message: /url/i,
344
+ },
345
+ {
346
+ name: "rejects a non-HTTP URL before fetching",
347
+ request: { target: "remote", url: "file:///tmp/provider" },
348
+ message: /url|http/i,
349
+ },
350
+ ];
351
+ for (const testCase of invalidCases) {
352
+ it(testCase.name, async () => {
353
+ const { fetch, calls } = createFetch(() => {
354
+ throw new Error("fetch must not run for invalid input");
355
+ });
356
+ const result = await probeEmbeddingEndpoint(testCase.request, {
357
+ fetch,
358
+ now: () => 5,
359
+ config: configWith(),
360
+ });
361
+ assert.equal(calls.length, 0);
362
+ assert.equal(result.ok, false);
363
+ assert.deepEqual(result.models, []);
364
+ assert.equal(result.selectedModelAvailable, null);
365
+ assert.equal(result.latencyMs, null);
366
+ assert.equal(result.error?.code, "invalid_request");
367
+ assert.match(result.error?.message ?? "", testCase.message);
368
+ });
369
+ }
370
+ it("maps HTTP 401 to unauthorized without returning the provider body", async () => {
371
+ const bodySentinel = "PROVIDER_401_BODY_SENTINEL";
372
+ const consoleEntries = captureConsole();
373
+ const { fetch } = createFetch(() => new Response(`credential rejected: ${bodySentinel}`, { status: 401 }));
374
+ const result = await probeEmbeddingEndpoint({ target: "remote", url: "https://probe.example.test" }, { fetch, now: () => 12, config: configWith() });
375
+ assert.equal(result.ok, false);
376
+ assert.equal(result.error?.code, "unauthorized");
377
+ assert.match(result.error?.message ?? "", /authoriz|credential/i);
378
+ assert.equal(JSON.stringify({ result, consoleEntries }).includes(bodySentinel), false);
379
+ });
380
+ it("maps fetch failures to unreachable without exposing transport details", async () => {
381
+ const transportSentinel = "TRANSPORT_DETAIL_SENTINEL";
382
+ const consoleEntries = captureConsole();
383
+ const { fetch } = createFetch(() => {
384
+ throw new TypeError(`fetch failed: ${transportSentinel}`);
385
+ });
386
+ const result = await probeEmbeddingEndpoint({ target: "local", url: "http://127.0.0.1:11434" }, { fetch, now: () => 12, config: configWith() });
387
+ assert.equal(result.ok, false);
388
+ assert.equal(result.error?.code, "unreachable");
389
+ assert.match(result.error?.message ?? "", /unreach|connect/i);
390
+ assert.equal(JSON.stringify({ result, consoleEntries }).includes(transportSentinel), false);
391
+ });
392
+ it("maps non-401 HTTP failures to provider_error without response-body or console leakage", async () => {
393
+ const bodySentinel = "PROVIDER_RESPONSE_BODY_SENTINEL";
394
+ const transientSentinel = "TRANSIENT_ERROR_PATH_SENTINEL";
395
+ const storedSentinel = "STORED_ERROR_PATH_SENTINEL";
396
+ const consoleEntries = captureConsole();
397
+ const { fetch } = createFetch(() => new Response(`upstream detail ${bodySentinel}`, { status: 503 }));
398
+ const result = await probeEmbeddingEndpoint({
399
+ target: "remote",
400
+ url: "https://probe.example.test",
401
+ transientSecrets: { readApiKey: transientSentinel },
402
+ }, {
403
+ fetch,
404
+ now: () => 12,
405
+ config: configWith({ remoteReadApiKey: storedSentinel }),
406
+ });
407
+ assert.equal(result.ok, false);
408
+ assert.equal(result.error?.code, "provider_error");
409
+ assert.match(result.error?.message ?? "", /provider|endpoint/i);
410
+ const observableText = JSON.stringify({ result, consoleEntries });
411
+ assert.equal(observableText.includes(bodySentinel), false);
412
+ assert.equal(observableText.includes(transientSentinel), false);
413
+ assert.equal(observableText.includes(storedSentinel), false);
414
+ });
415
+ it("treats a successful HTTP response with an unknown catalog shape as provider_error", async () => {
416
+ const malformedBodySentinel = "MALFORMED_CATALOG_SENTINEL";
417
+ const { fetch } = createFetch(() => jsonResponse({ models: "not-an-array", detail: malformedBodySentinel }));
418
+ const result = await probeEmbeddingEndpoint({ target: "local", url: "http://127.0.0.1:11434" }, { fetch, now: () => 20, config: configWith() });
419
+ assert.equal(result.ok, false);
420
+ assert.equal(result.error?.code, "provider_error");
421
+ assert.equal(JSON.stringify(result).includes(malformedBodySentinel), false);
422
+ });
423
+ });
424
+ });
@@ -0,0 +1,157 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
3
+ import { createProvider, OpenAICompatibleProvider } from "../src/embedding-providers.js";
4
+ describe("OpenAICompatibleProvider — authorization behavior", () => {
5
+ it("uses explicit apiKey when provided", async () => {
6
+ const fn = new OpenAICompatibleProvider({
7
+ url: "https://api.example.com/v1",
8
+ model: "text-embedding-3-small",
9
+ apiKey: "sk-real-token",
10
+ });
11
+ const seen = {};
12
+ mock.method(globalThis, "fetch", async (_url, init) => {
13
+ seen.authHeader = new Headers(init?.headers).get("Authorization") ?? undefined;
14
+ return new Response(JSON.stringify({ data: [{ embedding: [0.1] }] }), {
15
+ status: 200,
16
+ headers: { "Content-Type": "application/json" },
17
+ });
18
+ });
19
+ await fn.embed(["hello"], "query");
20
+ assert.equal(seen.authHeader, "Bearer sk-real-token");
21
+ });
22
+ it("omits Authorization header when apiKey is explicitly null", async () => {
23
+ // Some local servers reject 'Bearer ollama' as invalid auth.
24
+ // Callers can opt out by passing `apiKey: null`.
25
+ const fn = new OpenAICompatibleProvider({
26
+ url: "http://127.0.0.1:11434/v1",
27
+ model: "nomic-embed-text",
28
+ apiKey: null,
29
+ });
30
+ const seen = {};
31
+ mock.method(globalThis, "fetch", async (_url, init) => {
32
+ seen.authHeader = new Headers(init?.headers).get("Authorization") ?? undefined;
33
+ return new Response(JSON.stringify({ data: [{ embedding: [0.1] }] }), {
34
+ status: 200,
35
+ headers: { "Content-Type": "application/json" },
36
+ });
37
+ });
38
+ await fn.embed(["hello"], "query");
39
+ assert.equal(seen.authHeader, undefined);
40
+ });
41
+ });
42
+ describe("OpenAICompatibleProvider — embed() contract", () => {
43
+ afterEach(() => mock.restoreAll());
44
+ it("returns one vector per text, in order", async () => {
45
+ const fn = new OpenAICompatibleProvider({
46
+ url: "http://127.0.0.1:11434/v1",
47
+ model: "nomic-embed-text",
48
+ apiKey: "ollama",
49
+ });
50
+ mock.method(globalThis, "fetch", async () => {
51
+ return new Response(JSON.stringify({
52
+ data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }, { embedding: [0.5, 0.6] }],
53
+ }), { status: 200, headers: { "Content-Type": "application/json" } });
54
+ });
55
+ const vecs = await fn.embed(["a", "b", "c"], "document");
56
+ assert.deepEqual(vecs, [
57
+ [0.1, 0.2],
58
+ [0.3, 0.4],
59
+ [0.5, 0.6],
60
+ ]);
61
+ });
62
+ it("throws on a non-2xx response with the status code + body in the message", async () => {
63
+ const fn = new OpenAICompatibleProvider({
64
+ url: "http://127.0.0.1:11434/v1",
65
+ model: "nomic-embed-text",
66
+ });
67
+ mock.method(globalThis, "fetch", async () => {
68
+ return new Response("rate limit exceeded", { status: 429 });
69
+ });
70
+ await assert.rejects(() => fn.embed(["a"], "query"), /Embed failed: 429 rate limit exceeded/);
71
+ });
72
+ it("throws on a malformed response (missing `data` array)", async () => {
73
+ const fn = new OpenAICompatibleProvider({
74
+ url: "http://127.0.0.1:11434/v1",
75
+ model: "nomic-embed-text",
76
+ });
77
+ mock.method(globalThis, "fetch", async () => {
78
+ return new Response(JSON.stringify({ error: "oops" }), {
79
+ status: 200,
80
+ headers: { "Content-Type": "application/json" },
81
+ });
82
+ });
83
+ await assert.rejects(() => fn.embed(["a"], "query"), /Invalid embedding response/);
84
+ });
85
+ });
86
+ describe("createProvider — factory resolution order", () => {
87
+ let seenAuthHeaders;
88
+ afterEach(() => mock.restoreAll());
89
+ const baseCfg = {
90
+ dataDir: ".lancedb",
91
+ embedding: { localUrl: "http://127.0.0.1:11434/v1" },
92
+ };
93
+ beforeEach(() => {
94
+ seenAuthHeaders = [];
95
+ mock.method(globalThis, "fetch", async (_url, init) => {
96
+ seenAuthHeaders.push(new Headers(init?.headers).get("Authorization") ?? undefined);
97
+ return new Response(JSON.stringify({ data: [{ embedding: [0.1] }] }), {
98
+ status: 200,
99
+ headers: { "Content-Type": "application/json" },
100
+ });
101
+ });
102
+ });
103
+ it("picks OpenAICompatible when localUrl is set", async () => {
104
+ const p = await createProvider(baseCfg);
105
+ assert.equal(p.id.startsWith("openai-compatible:"), true);
106
+ assert.equal(p.dim(), undefined);
107
+ });
108
+ it("picks Modal when remoteUrl is set (remoteUrl wins over localUrl)", async () => {
109
+ const cfg = {
110
+ ...baseCfg,
111
+ embedding: {
112
+ ...baseCfg.embedding,
113
+ remoteUrl: "https://example.modal.run",
114
+ },
115
+ };
116
+ const p = await createProvider(cfg);
117
+ assert.equal(p.id.startsWith("modal:"), true);
118
+ });
119
+ it("throws when no provider is configured and useTransformers is false", async () => {
120
+ const cfg = {
121
+ dataDir: ".lancedb",
122
+ embedding: { useTransformers: false },
123
+ };
124
+ await assert.rejects(() => createProvider(cfg), /No embedding provider configured/);
125
+ });
126
+ it("local provider resolves PVM_LOCAL_EMBEDDING_API_KEY independently", async () => {
127
+ const savedLocal = process.env.PVM_LOCAL_EMBEDDING_API_KEY;
128
+ const savedBridge = process.env.PVM_API_TOKEN;
129
+ process.env.PVM_LOCAL_EMBEDDING_API_KEY = "LOCAL_PROVIDER_SENTINEL";
130
+ process.env.PVM_API_TOKEN = "BRIDGE_ONLY_SENTINEL";
131
+ try {
132
+ const provider = await createProvider(baseCfg);
133
+ const vectors = await provider.embed(["contract"], "query");
134
+ assert.deepEqual(vectors, [[0.1]]);
135
+ assert.deepEqual(seenAuthHeaders, [
136
+ "Bearer LOCAL_PROVIDER_SENTINEL",
137
+ "Bearer LOCAL_PROVIDER_SENTINEL",
138
+ ]);
139
+ assert.equal(JSON.stringify(vectors).includes("LOCAL_PROVIDER_SENTINEL"), false);
140
+ assert.equal(JSON.stringify(vectors).includes("BRIDGE_ONLY_SENTINEL"), false);
141
+ }
142
+ finally {
143
+ if (savedLocal === undefined)
144
+ delete process.env.PVM_LOCAL_EMBEDDING_API_KEY;
145
+ else
146
+ process.env.PVM_LOCAL_EMBEDDING_API_KEY = savedLocal;
147
+ if (savedBridge === undefined)
148
+ delete process.env.PVM_API_TOKEN;
149
+ else
150
+ process.env.PVM_API_TOKEN = savedBridge;
151
+ }
152
+ });
153
+ it("passes the dims hint to the OpenAI-compatible provider", async () => {
154
+ const p = await createProvider(baseCfg, { dims: 768 });
155
+ assert.equal(p.dim(), 768);
156
+ });
157
+ });