@malloy-publisher/server 0.0.230 → 0.0.232

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 (79) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +74 -3
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
  14. package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
  15. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  16. package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
  17. package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
  18. package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +3186 -433
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/errors.ts +19 -0
  32. package/src/logger.spec.ts +18 -1
  33. package/src/logger.ts +3 -1
  34. package/src/materialization_metrics.spec.ts +89 -4
  35. package/src/materialization_metrics.ts +155 -5
  36. package/src/mcp/skills/skills_bundle.json +1 -1
  37. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  38. package/src/mcp/tools/embedding_index.ts +808 -0
  39. package/src/mcp/tools/get_context_eval.ts +194 -45
  40. package/src/mcp/tools/get_context_tool.spec.ts +295 -2
  41. package/src/mcp/tools/get_context_tool.ts +159 -10
  42. package/src/pg_helpers.spec.ts +201 -0
  43. package/src/pg_helpers.ts +44 -5
  44. package/src/server.ts +24 -0
  45. package/src/service/build_plan.spec.ts +128 -2
  46. package/src/service/build_plan.ts +239 -17
  47. package/src/service/connection.ts +263 -7
  48. package/src/service/connection_config.spec.ts +48 -0
  49. package/src/service/connection_config.ts +21 -8
  50. package/src/service/connection_federation.spec.ts +184 -0
  51. package/src/service/embedding_provider.spec.ts +329 -0
  52. package/src/service/embedding_provider.ts +236 -0
  53. package/src/service/environment.ts +274 -12
  54. package/src/service/environment_store.spec.ts +678 -3
  55. package/src/service/environment_store.ts +449 -33
  56. package/src/service/environment_store_clone.spec.ts +350 -0
  57. package/src/service/manifest_loader.spec.ts +68 -13
  58. package/src/service/manifest_loader.ts +67 -19
  59. package/src/service/materialization_build_session.spec.ts +435 -0
  60. package/src/service/materialization_build_session.ts +681 -0
  61. package/src/service/materialization_eligibility.spec.ts +158 -0
  62. package/src/service/materialization_eligibility.ts +305 -0
  63. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  64. package/src/service/materialization_serve_transform.ts +779 -0
  65. package/src/service/materialization_service.spec.ts +774 -7
  66. package/src/service/materialization_service.ts +1107 -42
  67. package/src/service/materialization_test_fixtures.ts +7 -0
  68. package/src/service/model.spec.ts +207 -0
  69. package/src/service/model.ts +540 -52
  70. package/src/service/model_storage_serve.spec.ts +193 -0
  71. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  72. package/src/service/package.spec.ts +196 -0
  73. package/src/service/package.ts +385 -17
  74. package/src/service/persistence_policy.spec.ts +109 -0
  75. package/src/storage/duckdb/schema.ts +37 -0
  76. package/tests/fixtures/xlsx/database.xlsx +0 -0
  77. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  78. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  79. package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
@@ -0,0 +1,329 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ EmbeddingProvider,
4
+ MAX_EMBED_BATCH_SIZE,
5
+ MAX_EMBED_INPUT_CHARS,
6
+ _clearEmbeddingProviderForTests,
7
+ embeddingConfigured,
8
+ getEmbeddingProvider,
9
+ prepareEmbeddingInput,
10
+ } from "./embedding_provider";
11
+ import { EmbeddingConfig } from "../config";
12
+
13
+ const CONFIG: EmbeddingConfig = {
14
+ apiKey: "test-key",
15
+ model: "test-model",
16
+ baseUrl: "https://embeddings.example.com/v1",
17
+ };
18
+
19
+ interface CapturedRequest {
20
+ url: string;
21
+ body: Record<string, unknown>;
22
+ authorization: string | undefined;
23
+ }
24
+
25
+ /** A fetch stub that records requests and answers with unit vectors. */
26
+ function stubFetch(
27
+ captured: CapturedRequest[],
28
+ respond?: (inputs: string[]) => Response,
29
+ ): typeof fetch {
30
+ return (async (url: RequestInfo | URL, init?: RequestInit) => {
31
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
32
+ captured.push({
33
+ url: String(url),
34
+ body,
35
+ authorization: (init?.headers as Record<string, string>)?.[
36
+ "Authorization"
37
+ ],
38
+ });
39
+ const inputs = body.input as string[];
40
+ if (respond) return respond(inputs);
41
+ return new Response(
42
+ JSON.stringify({
43
+ data: inputs.map((_, index) => ({ index, embedding: [index, 1] })),
44
+ }),
45
+ { status: 200 },
46
+ );
47
+ }) as typeof fetch;
48
+ }
49
+
50
+ describe("prepareEmbeddingInput", () => {
51
+ it("collapses whitespace and newlines", () => {
52
+ expect(prepareEmbeddingInput("a\nb\t c d")).toBe("a b c d");
53
+ });
54
+
55
+ it("caps input length", () => {
56
+ const long = "x".repeat(MAX_EMBED_INPUT_CHARS + 500);
57
+ expect(prepareEmbeddingInput(long).length).toBe(MAX_EMBED_INPUT_CHARS);
58
+ });
59
+
60
+ it("never returns an empty string (providers 400 on empty inputs)", () => {
61
+ expect(prepareEmbeddingInput("")).toBe("-");
62
+ expect(prepareEmbeddingInput(" ")).toBe("-");
63
+ expect(prepareEmbeddingInput("\n\t")).toBe("-");
64
+ });
65
+ });
66
+
67
+ describe("EmbeddingProvider", () => {
68
+ it("embeds a batch and returns vectors in input order", async () => {
69
+ const captured: CapturedRequest[] = [];
70
+ const provider = new EmbeddingProvider(CONFIG, stubFetch(captured));
71
+ const vectors = await provider.embedBatch(["a", "b"], 1000);
72
+ expect(vectors).toEqual([
73
+ [0, 1],
74
+ [1, 1],
75
+ ]);
76
+ expect(captured).toHaveLength(1);
77
+ expect(captured[0].url).toBe(
78
+ "https://embeddings.example.com/v1/embeddings",
79
+ );
80
+ expect(captured[0].body.model).toBe("test-model");
81
+ expect(captured[0].authorization).toBe("Bearer test-key");
82
+ });
83
+
84
+ it("places vectors by the response's index fields, not response order", async () => {
85
+ const captured: CapturedRequest[] = [];
86
+ const provider = new EmbeddingProvider(
87
+ CONFIG,
88
+ stubFetch(
89
+ captured,
90
+ () =>
91
+ new Response(
92
+ JSON.stringify({
93
+ data: [
94
+ { index: 1, embedding: [2, 2] },
95
+ { index: 0, embedding: [1, 1] },
96
+ ],
97
+ }),
98
+ { status: 200 },
99
+ ),
100
+ ),
101
+ );
102
+ const vectors = await provider.embedBatch(["a", "b"], 1000);
103
+ expect(vectors).toEqual([
104
+ [1, 1],
105
+ [2, 2],
106
+ ]);
107
+ });
108
+
109
+ it("splits large inputs into batches", async () => {
110
+ const captured: CapturedRequest[] = [];
111
+ const provider = new EmbeddingProvider(CONFIG, stubFetch(captured));
112
+ const texts = Array.from(
113
+ { length: MAX_EMBED_BATCH_SIZE + 1 },
114
+ (_, i) => `t${i}`,
115
+ );
116
+ const vectors = await provider.embedBatch(texts, 1000);
117
+ expect(vectors).toHaveLength(texts.length);
118
+ expect(captured).toHaveLength(2);
119
+ expect((captured[0].body.input as string[]).length).toBe(
120
+ MAX_EMBED_BATCH_SIZE,
121
+ );
122
+ expect((captured[1].body.input as string[]).length).toBe(1);
123
+ });
124
+
125
+ it("sends the dimensions param only when configured", async () => {
126
+ const captured: CapturedRequest[] = [];
127
+ const without = new EmbeddingProvider(CONFIG, stubFetch(captured));
128
+ await without.embedBatch(["a"], 1000);
129
+ expect(captured[0].body.dimensions).toBeUndefined();
130
+
131
+ const withDims = new EmbeddingProvider(
132
+ { ...CONFIG, dimensions: 512 },
133
+ stubFetch(captured),
134
+ );
135
+ await withDims.embedBatch(["a"], 1000);
136
+ expect(captured[1].body.dimensions).toBe(512);
137
+ });
138
+
139
+ it("reports HTTP failures with a truncated body and no header echo", async () => {
140
+ const provider = new EmbeddingProvider(
141
+ CONFIG,
142
+ stubFetch(
143
+ [],
144
+ () => new Response("boom ".repeat(100), { status: 429 }),
145
+ ),
146
+ );
147
+ const err = await provider.embedBatch(["a"], 1000).then(
148
+ () => undefined,
149
+ (e: Error) => e,
150
+ );
151
+ expect(err).toBeDefined();
152
+ expect(err!.message).toContain("(429)");
153
+ expect(err!.message.length).toBeLessThan(400);
154
+ expect(err!.message).not.toContain("test-key");
155
+ });
156
+
157
+ it("drops auth-failure bodies entirely (they can reflect the key)", async () => {
158
+ const provider = new EmbeddingProvider(
159
+ CONFIG,
160
+ stubFetch(
161
+ [],
162
+ () =>
163
+ new Response("Incorrect API key provided: test-key", {
164
+ status: 401,
165
+ }),
166
+ ),
167
+ );
168
+ const err = await provider.embedBatch(["a"], 1000).then(
169
+ () => undefined,
170
+ (e: Error) => e,
171
+ );
172
+ expect(err!.message).toContain("(401)");
173
+ expect(err!.message).toContain("authentication failed");
174
+ expect(err!.message).not.toContain("test-key");
175
+ });
176
+
177
+ it("scrubs literal key occurrences from non-auth error bodies", async () => {
178
+ const provider = new EmbeddingProvider(
179
+ CONFIG,
180
+ stubFetch(
181
+ [],
182
+ () =>
183
+ new Response(
184
+ "proxy error while forwarding token test-key upstream",
185
+ {
186
+ status: 502,
187
+ },
188
+ ),
189
+ ),
190
+ );
191
+ const err = await provider.embedBatch(["a"], 1000).then(
192
+ () => undefined,
193
+ (e: Error) => e,
194
+ );
195
+ expect(err!.message).toContain("(502)");
196
+ expect(err!.message).toContain("[REDACTED]");
197
+ expect(err!.message).not.toContain("test-key");
198
+ });
199
+
200
+ it("names a timeout in the failure message", async () => {
201
+ const timeoutFetch = (async () => {
202
+ const error = new Error("The operation timed out");
203
+ error.name = "TimeoutError";
204
+ throw error;
205
+ }) as unknown as typeof fetch;
206
+ const provider = new EmbeddingProvider(CONFIG, timeoutFetch);
207
+ const err = await provider.embedBatch(["a"], 1234).then(
208
+ () => undefined,
209
+ (e: Error) => e,
210
+ );
211
+ expect(err!.message).toContain("timed out after 1234ms");
212
+ });
213
+
214
+ it("rejects a response with the wrong number of embeddings", async () => {
215
+ const provider = new EmbeddingProvider(
216
+ CONFIG,
217
+ stubFetch(
218
+ [],
219
+ () =>
220
+ new Response(
221
+ JSON.stringify({ data: [{ index: 0, embedding: [1] }] }),
222
+ { status: 200 },
223
+ ),
224
+ ),
225
+ );
226
+ await expect(provider.embedBatch(["a", "b"], 1000)).rejects.toThrow(
227
+ "malformed",
228
+ );
229
+ });
230
+
231
+ it("rejects a vector with non-finite or non-numeric contents", async () => {
232
+ const provider = new EmbeddingProvider(
233
+ CONFIG,
234
+ stubFetch(
235
+ [],
236
+ () =>
237
+ new Response(
238
+ JSON.stringify({
239
+ data: [{ index: 0, embedding: [1, null, "x"] }],
240
+ }),
241
+ { status: 200 },
242
+ ),
243
+ ),
244
+ );
245
+ await expect(provider.embedBatch(["a"], 1000)).rejects.toThrow(
246
+ "malformed",
247
+ );
248
+ });
249
+ });
250
+
251
+ describe("getEmbeddingProvider / embeddingConfigured", () => {
252
+ const ENV_KEYS = [
253
+ "EMBEDDING_API_KEY",
254
+ "EMBEDDING_MODEL",
255
+ "EMBEDDING_API_BASE",
256
+ "EMBEDDING_DIMENSIONS",
257
+ ];
258
+ const saved: Record<string, string | undefined> = {};
259
+
260
+ beforeEach(() => {
261
+ for (const key of ENV_KEYS) {
262
+ saved[key] = process.env[key];
263
+ delete process.env[key];
264
+ }
265
+ _clearEmbeddingProviderForTests();
266
+ });
267
+
268
+ afterEach(() => {
269
+ for (const key of ENV_KEYS) {
270
+ if (saved[key] === undefined) delete process.env[key];
271
+ else process.env[key] = saved[key];
272
+ }
273
+ _clearEmbeddingProviderForTests();
274
+ });
275
+
276
+ it("returns null (feature off) without EMBEDDING_API_KEY", () => {
277
+ expect(getEmbeddingProvider()).toBeNull();
278
+ expect(embeddingConfigured()).toBe(false);
279
+ });
280
+
281
+ it("ignores an ambient OPENAI_API_KEY", () => {
282
+ const before = process.env.OPENAI_API_KEY;
283
+ process.env.OPENAI_API_KEY = "ambient";
284
+ try {
285
+ expect(getEmbeddingProvider()).toBeNull();
286
+ expect(embeddingConfigured()).toBe(false);
287
+ } finally {
288
+ if (before === undefined) delete process.env.OPENAI_API_KEY;
289
+ else process.env.OPENAI_API_KEY = before;
290
+ }
291
+ });
292
+
293
+ it("builds a provider with defaults when the key is set", () => {
294
+ process.env.EMBEDDING_API_KEY = "k";
295
+ const provider = getEmbeddingProvider();
296
+ expect(provider).not.toBeNull();
297
+ expect(provider!.model).toBe("text-embedding-3-small");
298
+ expect(embeddingConfigured()).toBe(true);
299
+ });
300
+
301
+ it("rebuilds the provider when the configuration changes", () => {
302
+ process.env.EMBEDDING_API_KEY = "k";
303
+ const first = getEmbeddingProvider();
304
+ expect(getEmbeddingProvider()).toBe(first);
305
+ process.env.EMBEDDING_MODEL = "other-model";
306
+ const second = getEmbeddingProvider();
307
+ expect(second).not.toBe(first);
308
+ expect(second!.model).toBe("other-model");
309
+ });
310
+
311
+ it("does not stick to null after the key appears", () => {
312
+ expect(getEmbeddingProvider()).toBeNull();
313
+ process.env.EMBEDDING_API_KEY = "k";
314
+ expect(getEmbeddingProvider()).not.toBeNull();
315
+ });
316
+
317
+ it("throws on a malformed base URL but still reports configured", () => {
318
+ process.env.EMBEDDING_API_KEY = "k";
319
+ process.env.EMBEDDING_API_BASE = "not a url";
320
+ expect(() => getEmbeddingProvider()).toThrow("EMBEDDING_API_BASE");
321
+ expect(embeddingConfigured()).toBe(true);
322
+ });
323
+
324
+ it("throws on a malformed EMBEDDING_DIMENSIONS", () => {
325
+ process.env.EMBEDDING_API_KEY = "k";
326
+ process.env.EMBEDDING_DIMENSIONS = "lots";
327
+ expect(() => getEmbeddingProvider()).toThrow("EMBEDDING_DIMENSIONS");
328
+ });
329
+ });
@@ -0,0 +1,236 @@
1
+ import { EmbeddingConfig, getEmbeddingConfig } from "../config";
2
+
3
+ /** Timeout for bulk (index-build) embedding calls. */
4
+ export const EMBEDDING_BATCH_TIMEOUT_MS = 30_000;
5
+ /**
6
+ * Timeout for the single per-request query embedding. Much shorter than
7
+ * the bulk timeout: this call sits on the latency path of every semantic
8
+ * `malloy_getContext` call, and a slow endpoint must degrade to lexical
9
+ * quickly rather than stall the tool.
10
+ */
11
+ export const EMBEDDING_QUERY_TIMEOUT_MS = 5_000;
12
+ /** Per-input character cap (matches the hosted indexing pipeline). */
13
+ export const MAX_EMBED_INPUT_CHARS = 1_024;
14
+ /** Inputs per HTTP request; large packages embed in several requests. */
15
+ export const MAX_EMBED_BATCH_SIZE = 512;
16
+
17
+ type FetchFn = typeof fetch;
18
+
19
+ interface EmbeddingResponseItem {
20
+ index?: number;
21
+ embedding?: number[];
22
+ }
23
+
24
+ /**
25
+ * Collapse whitespace (newlines confuse some embedding models) and cap
26
+ * the input length, mirroring the hosted indexing pipeline's input
27
+ * preparation. Never returns an empty string: providers reject empty
28
+ * input items with a 400, which would fail a whole package's batch, so
29
+ * text that collapses to nothing (a whitespace-only backtick identifier)
30
+ * becomes a placeholder token instead. This is the single choke point
31
+ * for that invariant; the index hashes this same function's output, so
32
+ * hash and sent text cannot drift. Exported for tests and for
33
+ * content-hashing: the hash must cover the text actually sent, so a
34
+ * truncation-boundary edit re-embeds.
35
+ */
36
+ export function prepareEmbeddingInput(text: string): string {
37
+ const cleaned = text.replace(/\s+/g, " ").trim();
38
+ const capped =
39
+ cleaned.length > MAX_EMBED_INPUT_CHARS
40
+ ? cleaned.slice(0, MAX_EMBED_INPUT_CHARS)
41
+ : cleaned;
42
+ return capped || "-";
43
+ }
44
+
45
+ /**
46
+ * Minimal client for an OpenAI-compatible `/embeddings` endpoint, called
47
+ * with global fetch (no provider SDK; see fetchFromPublisherDataplane for
48
+ * the outbound-HTTP precedent this follows). The API key travels only in
49
+ * the Authorization header and must never be logged; error messages carry
50
+ * at most a 200-char body excerpt and never echo request headers.
51
+ */
52
+ export class EmbeddingProvider {
53
+ constructor(
54
+ private config: EmbeddingConfig,
55
+ private fetchFn: FetchFn = fetch,
56
+ ) {}
57
+
58
+ get model(): string {
59
+ return this.config.model;
60
+ }
61
+
62
+ get dimensions(): number | undefined {
63
+ return this.config.dimensions;
64
+ }
65
+
66
+ /**
67
+ * Embed `texts` in order. Inputs are prepared (whitespace-collapsed,
68
+ * capped) and sent in batches of {@link MAX_EMBED_BATCH_SIZE}. Throws
69
+ * on any HTTP, timeout, or malformed-response failure; callers own the
70
+ * fallback-to-lexical decision.
71
+ */
72
+ async embedBatch(texts: string[], timeoutMs: number): Promise<number[][]> {
73
+ const vectors: number[][] = [];
74
+ for (let i = 0; i < texts.length; i += MAX_EMBED_BATCH_SIZE) {
75
+ const chunk = texts
76
+ .slice(i, i + MAX_EMBED_BATCH_SIZE)
77
+ .map(prepareEmbeddingInput);
78
+ vectors.push(...(await this.embedChunk(chunk, timeoutMs)));
79
+ }
80
+ return vectors;
81
+ }
82
+
83
+ private async embedChunk(
84
+ inputs: string[],
85
+ timeoutMs: number,
86
+ ): Promise<number[][]> {
87
+ const url = `${this.config.baseUrl}/embeddings`;
88
+ const body: Record<string, unknown> = {
89
+ model: this.config.model,
90
+ input: inputs,
91
+ };
92
+ if (this.config.dimensions !== undefined) {
93
+ body.dimensions = this.config.dimensions;
94
+ }
95
+
96
+ let response: Response;
97
+ try {
98
+ response = await this.fetchFn(url, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ Authorization: `Bearer ${this.config.apiKey}`,
103
+ },
104
+ body: JSON.stringify(body),
105
+ signal: AbortSignal.timeout(timeoutMs),
106
+ });
107
+ } catch (error) {
108
+ const reason =
109
+ (error as Error)?.name === "TimeoutError"
110
+ ? `timed out after ${timeoutMs}ms`
111
+ : (error as Error).message;
112
+ throw new Error(`Embedding request to ${url} failed: ${reason}`);
113
+ }
114
+
115
+ if (!response.ok) {
116
+ // Auth-failure bodies commonly reflect the presented credential
117
+ // (OpenAI's 401 echoes partial key characters; a self-hosted
118
+ // proxy may echo the whole token), and this message is logged
119
+ // by callers. Drop those bodies entirely, and scrub any literal
120
+ // occurrence of the key from the rest.
121
+ let detail: string;
122
+ if (response.status === 401 || response.status === 403) {
123
+ detail = "authentication failed; check EMBEDDING_API_KEY";
124
+ } else {
125
+ const bodyText = await response.text().catch(() => "");
126
+ detail = bodyText
127
+ .split(this.config.apiKey)
128
+ .join("[REDACTED]")
129
+ .slice(0, 200);
130
+ }
131
+ throw new Error(
132
+ `Embedding request to ${url} failed (${response.status}): ${detail}`,
133
+ );
134
+ }
135
+
136
+ const json = (await response.json()) as {
137
+ data?: EmbeddingResponseItem[];
138
+ };
139
+ const data = json?.data;
140
+ if (!Array.isArray(data) || data.length !== inputs.length) {
141
+ throw new Error(
142
+ `Embedding response from ${url} malformed: expected ${inputs.length} embeddings, got ${Array.isArray(data) ? data.length : "none"}`,
143
+ );
144
+ }
145
+
146
+ // The response documents per-item `index` fields; place by index
147
+ // defensively rather than assuming order.
148
+ const vectors = new Array<number[] | undefined>(inputs.length);
149
+ for (let i = 0; i < data.length; i++) {
150
+ const item = data[i];
151
+ const idx = typeof item?.index === "number" ? item.index : i;
152
+ if (
153
+ !Array.isArray(item?.embedding) ||
154
+ item.embedding.length === 0 ||
155
+ // Numeric contents, not just shape: a provider returning
156
+ // [1, null, "x"] would otherwise be stored and misbehave
157
+ // silently (NaN scores) at search time.
158
+ !item.embedding.every(
159
+ (n: unknown) => typeof n === "number" && Number.isFinite(n),
160
+ ) ||
161
+ idx < 0 ||
162
+ idx >= inputs.length ||
163
+ vectors[idx] !== undefined
164
+ ) {
165
+ throw new Error(
166
+ `Embedding response from ${url} malformed at item ${i}`,
167
+ );
168
+ }
169
+ vectors[idx] = item.embedding;
170
+ }
171
+ return vectors as number[][];
172
+ }
173
+ }
174
+
175
+ // Cached on a config fingerprint, never on null: a call after the env
176
+ // changes (tests, operator restarts with new vars are moot, but the
177
+ // integration suite runs many specs in one process) always sees the
178
+ // current configuration instead of a stale provider or a sticky "off".
179
+ let cached: { fingerprint: string; provider: EmbeddingProvider } | null = null;
180
+ let testOverride: { provider: EmbeddingProvider | null } | null = null;
181
+
182
+ /**
183
+ * Whether the operator has turned the embedding feature on at all.
184
+ * Malformed companion vars still count as configured: the operator
185
+ * clearly intends the feature, so the retrieval marker and a warning
186
+ * must surface rather than silently reporting plain lexical.
187
+ */
188
+ export function embeddingConfigured(): boolean {
189
+ if (testOverride) {
190
+ return testOverride.provider !== null;
191
+ }
192
+ try {
193
+ return getEmbeddingConfig() !== null;
194
+ } catch {
195
+ return true;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * The process-wide provider for the current embedding configuration, or
201
+ * null when `EMBEDDING_API_KEY` is unset. Throws on malformed companion
202
+ * env vars (see getEmbeddingConfig); callers on the tool path catch and
203
+ * degrade.
204
+ */
205
+ export function getEmbeddingProvider(): EmbeddingProvider | null {
206
+ if (testOverride) {
207
+ return testOverride.provider;
208
+ }
209
+ const config = getEmbeddingConfig();
210
+ if (!config) {
211
+ cached = null;
212
+ return null;
213
+ }
214
+ const fingerprint = [
215
+ config.baseUrl,
216
+ config.model,
217
+ config.dimensions ?? "",
218
+ config.apiKey,
219
+ ].join("\u0000");
220
+ if (!cached || cached.fingerprint !== fingerprint) {
221
+ cached = { fingerprint, provider: new EmbeddingProvider(config) };
222
+ }
223
+ return cached.provider;
224
+ }
225
+
226
+ /** Test seam: force the provider (or null). Undo with _clear...(). */
227
+ export function _setEmbeddingProviderForTests(
228
+ provider: EmbeddingProvider | null,
229
+ ): void {
230
+ testOverride = { provider };
231
+ }
232
+
233
+ export function _clearEmbeddingProviderForTests(): void {
234
+ testOverride = null;
235
+ cached = null;
236
+ }