@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.
- package/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- 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
|
+
}
|