@untangle-code/embeddings-ollama 0.0.0-stage → 0.1.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 (75) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +187 -2
  4. package/THIRD_PARTY_LICENSES.md +24 -0
  5. package/dist/backoff.d.ts +43 -0
  6. package/dist/backoff.d.ts.map +1 -0
  7. package/dist/backoff.js +38 -0
  8. package/dist/backoff.js.map +1 -0
  9. package/dist/cache.d.ts +17 -0
  10. package/dist/cache.d.ts.map +1 -0
  11. package/dist/cache.js +17 -0
  12. package/dist/cache.js.map +1 -0
  13. package/dist/concurrency.d.ts +11 -0
  14. package/dist/concurrency.d.ts.map +1 -0
  15. package/dist/concurrency.js +31 -0
  16. package/dist/concurrency.js.map +1 -0
  17. package/dist/endpoint.d.ts +16 -0
  18. package/dist/endpoint.d.ts.map +1 -0
  19. package/dist/endpoint.js +32 -0
  20. package/dist/endpoint.js.map +1 -0
  21. package/dist/http.d.ts +54 -0
  22. package/dist/http.d.ts.map +1 -0
  23. package/dist/http.js +136 -0
  24. package/dist/http.js.map +1 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +18 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/inflight.d.ts +49 -0
  30. package/dist/inflight.d.ts.map +1 -0
  31. package/dist/inflight.js +87 -0
  32. package/dist/inflight.js.map +1 -0
  33. package/dist/ollama-api.d.ts +45 -0
  34. package/dist/ollama-api.d.ts.map +1 -0
  35. package/dist/ollama-api.js +40 -0
  36. package/dist/ollama-api.js.map +1 -0
  37. package/dist/provider.d.ts +111 -0
  38. package/dist/provider.d.ts.map +1 -0
  39. package/dist/provider.js +374 -0
  40. package/dist/provider.js.map +1 -0
  41. package/dist/readiness.d.ts +31 -0
  42. package/dist/readiness.d.ts.map +1 -0
  43. package/dist/readiness.js +105 -0
  44. package/dist/readiness.js.map +1 -0
  45. package/dist/sleep.d.ts +8 -0
  46. package/dist/sleep.d.ts.map +1 -0
  47. package/dist/sleep.js +20 -0
  48. package/dist/sleep.js.map +1 -0
  49. package/dist/truncation.d.ts +30 -0
  50. package/dist/truncation.d.ts.map +1 -0
  51. package/dist/truncation.js +39 -0
  52. package/dist/truncation.js.map +1 -0
  53. package/dist/types.d.ts +108 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +7 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/validation.d.ts +2 -0
  58. package/dist/validation.d.ts.map +1 -0
  59. package/dist/validation.js +16 -0
  60. package/dist/validation.js.map +1 -0
  61. package/package.json +62 -3
  62. package/src/backoff.ts +73 -0
  63. package/src/cache.ts +30 -0
  64. package/src/concurrency.ts +37 -0
  65. package/src/endpoint.ts +31 -0
  66. package/src/http.ts +168 -0
  67. package/src/index.ts +21 -0
  68. package/src/inflight.ts +102 -0
  69. package/src/ollama-api.ts +93 -0
  70. package/src/provider.ts +477 -0
  71. package/src/readiness.ts +203 -0
  72. package/src/sleep.ts +27 -0
  73. package/src/truncation.ts +41 -0
  74. package/src/types.ts +132 -0
  75. package/src/validation.ts +23 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Single-flight / in-flight de-duplication primitives (spec.md §9.4, WP-78).
3
+ *
4
+ * Used to make sure a running async operation for a given key is REUSED by
5
+ * concurrent callers instead of started twice — both for embedding a
6
+ * not-yet-cached text (`provider.ts`'s `embedSlice`) and for the readiness
7
+ * sequence (`ensureReadyOnce`). Deliberately generic and cache-agnostic:
8
+ * these helpers only manage the "who is doing this right now" bookkeeping,
9
+ * never the cache or the network call itself.
10
+ */
11
+
12
+ /** An externally-settleable promise: `resolve`/`reject` are exposed to the caller. */
13
+ export interface Deferred<T> {
14
+ promise: Promise<T>;
15
+ resolve: (value: T) => void;
16
+ reject: (reason: unknown) => void;
17
+ }
18
+
19
+ /** Creates a {@link Deferred}. The executor runs synchronously, so both fields are always assigned. */
20
+ export function createDeferred<T>(): Deferred<T> {
21
+ let resolve!: (value: T) => void;
22
+ let reject!: (reason: unknown) => void;
23
+ const promise = new Promise<T>((res, rej) => {
24
+ resolve = res;
25
+ reject = rej;
26
+ });
27
+ return { promise, resolve, reject };
28
+ }
29
+
30
+ function toAbortError(signal: AbortSignal): unknown {
31
+ return signal.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError");
32
+ }
33
+
34
+ /**
35
+ * Awaits `shared` — a promise potentially also being awaited by OTHER
36
+ * callers — but lets THIS caller's own `signal` reject early without
37
+ * affecting `shared` itself: the underlying operation keeps running (and
38
+ * other joined callers keep waiting on it) even after one waiter's own
39
+ * signal aborts (WP-78 requirement: one waiter's cancellation must not
40
+ * cancel the operation for everyone else).
41
+ */
42
+ export function raceWithSignal<T>(shared: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
43
+ if (!signal) {
44
+ return shared;
45
+ }
46
+ if (signal.aborted) {
47
+ return Promise.reject(toAbortError(signal));
48
+ }
49
+ return new Promise<T>((resolve, reject) => {
50
+ const onAbort = (): void => {
51
+ reject(toAbortError(signal));
52
+ };
53
+ signal.addEventListener("abort", onAbort, { once: true });
54
+ shared.then(
55
+ (value) => {
56
+ signal.removeEventListener("abort", onAbort);
57
+ resolve(value);
58
+ },
59
+ (error: unknown) => {
60
+ signal.removeEventListener("abort", onAbort);
61
+ reject(error);
62
+ },
63
+ );
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Registry of in-flight operations keyed by an arbitrary string. `claim`
69
+ * MUST be called synchronously (no `await`) between checking for an
70
+ * existing entry and registering a new one — see call sites in
71
+ * `provider.ts` — so that two concurrent callers observing the same "no
72
+ * entry yet" state cannot both start the operation (the actual JS-level
73
+ * atomicity comes from there being no `await` in the claiming code, not
74
+ * from this class).
75
+ */
76
+ export class InFlightRegistry<T> {
77
+ private readonly entries = new Map<string, Promise<T>>();
78
+
79
+ /** Returns the in-flight promise for `key`, if any is currently registered. */
80
+ public get(key: string): Promise<T> | undefined {
81
+ return this.entries.get(key);
82
+ }
83
+
84
+ /**
85
+ * Registers `promise` as the in-flight operation for `key`. The entry is
86
+ * removed automatically once `promise` settles — successfully or not — so
87
+ * a failed attempt frees the key immediately for a fresh retry (WP-78:
88
+ * "ein Fehlschlag den Eintrag wieder freigibt").
89
+ */
90
+ public register(key: string, promise: Promise<T>): void {
91
+ this.entries.set(key, promise);
92
+ // Void: this cleanup must never itself become an unhandled rejection —
93
+ // the caller is responsible for handling/propagating `promise`'s outcome.
94
+ void promise
95
+ .catch(() => undefined)
96
+ .finally(() => {
97
+ if (this.entries.get(key) === promise) {
98
+ this.entries.delete(key);
99
+ }
100
+ });
101
+ }
102
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Typed slice of the Ollama HTTP API this adapter uses: `/api/tags` (model
3
+ * listing / reachability probe), `/api/pull` (managed-mode model fetch) and
4
+ * `/api/embed` (batch embedding). Shapes are the subset of each response
5
+ * this adapter reads — Ollama's actual responses carry more fields.
6
+ */
7
+ import type { OllamaHttpClient } from "./http.js";
8
+
9
+ export interface OllamaTagV1 {
10
+ name: string;
11
+ model?: string;
12
+ }
13
+
14
+ export interface OllamaTagsResponseV1 {
15
+ models: OllamaTagV1[];
16
+ }
17
+
18
+ export interface OllamaPullResponseV1 {
19
+ status: string;
20
+ }
21
+
22
+ export interface OllamaEmbedResponseV1 {
23
+ model: string;
24
+ embeddings: number[][];
25
+ }
26
+
27
+ /** `GET /api/tags` — used both as the reachability probe and the model-availability check. */
28
+ export async function listTags(
29
+ client: OllamaHttpClient,
30
+ baseUrl: string,
31
+ signal?: AbortSignal,
32
+ ): Promise<OllamaTagsResponseV1> {
33
+ return client.requestJson<OllamaTagsResponseV1>(baseUrl, "api/tags", { method: "GET" }, signal);
34
+ }
35
+
36
+ /**
37
+ * True when `modelTags` (as returned by {@link listTags}) contains
38
+ * `modelName`. Ollama tag names carry an implicit `:latest` suffix when the
39
+ * configured model name has none, so `"nomic-embed-text"` must match a tag
40
+ * named `"nomic-embed-text:latest"`.
41
+ */
42
+ export function tagsContainModel(modelTags: OllamaTagV1[], modelName: string): boolean {
43
+ const wantsTag = modelName.includes(":");
44
+ return modelTags.some((tag) => {
45
+ const tagName = tag.name;
46
+ if (wantsTag) {
47
+ return tagName === modelName;
48
+ }
49
+ const [tagBase] = tagName.split(":");
50
+ return tagBase === modelName;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * `POST /api/pull` with `stream: false` — Ollama blocks until the pull
56
+ * completes (or fails) and returns a single JSON response, which keeps this
57
+ * call symmetric with every other request this client makes (one request,
58
+ * one JSON response, no NDJSON stream parsing).
59
+ */
60
+ export async function pullModel(
61
+ client: OllamaHttpClient,
62
+ baseUrl: string,
63
+ modelName: string,
64
+ signal?: AbortSignal,
65
+ ): Promise<OllamaPullResponseV1> {
66
+ return client.requestJson<OllamaPullResponseV1>(
67
+ baseUrl,
68
+ "api/pull",
69
+ { method: "POST", body: { model: modelName, stream: false } },
70
+ signal,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * `POST /api/embed`. `truncate: true` keeps Ollama's own truncation active
76
+ * as the last-resort safety net spec.md §9.3 describes; this adapter's own
77
+ * truncation guard (`truncation.ts`) decides the `embedding_truncated`
78
+ * payload flag independently of whether the server actually had to use it.
79
+ */
80
+ export async function embed(
81
+ client: OllamaHttpClient,
82
+ baseUrl: string,
83
+ modelName: string,
84
+ input: string[],
85
+ signal?: AbortSignal,
86
+ ): Promise<OllamaEmbedResponseV1> {
87
+ return client.requestJson<OllamaEmbedResponseV1>(
88
+ baseUrl,
89
+ "api/embed",
90
+ { method: "POST", body: { model: modelName, input, truncate: true } },
91
+ signal,
92
+ );
93
+ }
@@ -0,0 +1,477 @@
1
+ /**
2
+ * `EmbeddingProvider` port implementation for Ollama (domain `ports.ts`,
3
+ * spec.md §9, architecture.md §6.8).
4
+ */
5
+ import type {
6
+ Clock,
7
+ EmbeddingBatchResultV1,
8
+ EmbeddingProfile,
9
+ EmbeddingProvider,
10
+ EmbeddingReadinessV1,
11
+ PortCallOptions,
12
+ Random,
13
+ } from "@untangle-code/domain";
14
+ import { buildEmbeddingProfileId, isUntangleError, sha256Hex } from "@untangle-code/domain";
15
+ import { embeddingUnavailableError, jobCancelledError } from "@untangle-code/observability";
16
+
17
+ import type { BackoffConfig } from "./backoff.js";
18
+ import { withRetry } from "./backoff.js";
19
+ import { InMemoryEmbeddingCache } from "./cache.js";
20
+ import { mapWithConcurrency } from "./concurrency.js";
21
+ import { isRequestCancellation, OllamaHttpClient, OllamaHttpError } from "./http.js";
22
+ import type { Deferred } from "./inflight.js";
23
+ import { createDeferred, InFlightRegistry, raceWithSignal } from "./inflight.js";
24
+ import { embed } from "./ollama-api.js";
25
+ import { runReadinessSequence } from "./readiness.js";
26
+ import { realSleep } from "./sleep.js";
27
+ import { wouldProviderTruncate } from "./truncation.js";
28
+ import type {
29
+ EmbeddingBatchProgressV1,
30
+ EmbeddingCacheEntry,
31
+ EmbeddingCacheKey,
32
+ EmbeddingCachePort,
33
+ OllamaEmbeddingProviderConfig,
34
+ SleepFn,
35
+ } from "./types.js";
36
+ import { DEFAULT_BASE_BACKOFF_MS, DEFAULT_MAX_BACKOFF_MS, DEFAULT_MAX_RETRIES } from "./types.js";
37
+ import { assertVectorDimension } from "./validation.js";
38
+
39
+ export interface OllamaEmbeddingProviderDeps {
40
+ clock: Clock;
41
+ random: Random;
42
+ /** Defaults to {@link InMemoryEmbeddingCache} (spec.md §9.4). */
43
+ cache?: EmbeddingCachePort;
44
+ /** Defaults to {@link realSleep}; tests inject a fake that resolves instantly. */
45
+ sleep?: SleepFn;
46
+ /** Defaults to the global `fetch`; tests inject a fake transport. */
47
+ fetchImpl?: typeof fetch;
48
+ }
49
+
50
+ /**
51
+ * Ein von diesem Aufruf beanspruchter, noch nicht eingebetteter Schlüssel:
52
+ * In-flight-Platz (`keyString`), Cache-Schlüssel, Text und das Deferred, über
53
+ * das jeder beigetretene Aufrufer sein Ergebnis erhält.
54
+ */
55
+ interface ClaimedEmbedItem {
56
+ keyString: string;
57
+ key: EmbeddingCacheKey;
58
+ text: string;
59
+ deferred: Deferred<EmbeddingCacheEntry>;
60
+ }
61
+
62
+ function isRetryableEmbedError(error: unknown): boolean {
63
+ return error instanceof OllamaHttpError && error.retryable;
64
+ }
65
+
66
+ /** Same shape as `cache.ts`'s internal key string — local to this module, used only for in-flight bookkeeping. */
67
+ function cacheKeyString(key: EmbeddingCacheKey): string {
68
+ return `${key.embeddingProfileId}:${key.preparedTextHash}`;
69
+ }
70
+
71
+ /** Splits `items` into consecutive slices of at most `size` (the last one possibly shorter). */
72
+ function chunkIntoSlices<T>(items: readonly T[], size: number): T[][] {
73
+ if (items.length === 0) {
74
+ return [];
75
+ }
76
+ const slices: T[][] = [];
77
+ for (let start = 0; start < items.length; start += size) {
78
+ slices.push(items.slice(start, start + size));
79
+ }
80
+ return slices;
81
+ }
82
+
83
+ /** Normalizes any thrown value into the `UntangleError` this package's public API promises (rule 8). */
84
+ function normalizeError(error: unknown, signal: AbortSignal | undefined): unknown {
85
+ if (isUntangleError(error)) {
86
+ return error;
87
+ }
88
+ if (isRequestCancellation(error, signal)) {
89
+ return jobCancelledError("Ollama embedding operation was cancelled.", {
90
+ cause: error,
91
+ retryable: false,
92
+ });
93
+ }
94
+ if (error instanceof OllamaHttpError) {
95
+ return embeddingUnavailableError(error.message, {
96
+ cause: error,
97
+ retryable: false,
98
+ context: error.status === undefined ? {} : { status: error.status },
99
+ });
100
+ }
101
+ return embeddingUnavailableError(`Unexpected Ollama embedding provider error: ${String(error)}`, {
102
+ cause: error,
103
+ retryable: false,
104
+ });
105
+ }
106
+
107
+ async function guarded<T>(signal: AbortSignal | undefined, fn: () => Promise<T>): Promise<T> {
108
+ try {
109
+ signal?.throwIfAborted();
110
+ return await fn();
111
+ } catch (error) {
112
+ throw normalizeError(error, signal);
113
+ }
114
+ }
115
+
116
+ export class OllamaEmbeddingProvider implements EmbeddingProvider {
117
+ public readonly profile: EmbeddingProfile;
118
+ public readonly profileId: string;
119
+
120
+ private readonly config: OllamaEmbeddingProviderConfig;
121
+ private readonly clock: Clock;
122
+ private readonly random: Random;
123
+ private readonly cache: EmbeddingCachePort;
124
+ private readonly sleep: SleepFn;
125
+ private readonly httpClient: OllamaHttpClient;
126
+ private readonly backoffConfig: BackoffConfig;
127
+
128
+ private cachedReadiness: EmbeddingReadinessV1 | undefined;
129
+ private resolvedEndpointUrl: string | undefined;
130
+ private readyPromise: Promise<void> | undefined;
131
+
132
+ /**
133
+ * One shared fetch-and-cache operation per still-uncached cache key
134
+ * (spec.md §9.4, WP-78): concurrent `embedDocuments`/`embedQuery` calls
135
+ * that race for the same not-yet-cached text join the SAME `/api/embed`
136
+ * request instead of each starting their own.
137
+ */
138
+ private readonly inFlightEmbeds = new InFlightRegistry<EmbeddingCacheEntry>();
139
+
140
+ public constructor(config: OllamaEmbeddingProviderConfig, deps: OllamaEmbeddingProviderDeps) {
141
+ this.config = config;
142
+ this.profile = config.profile;
143
+ this.profileId = buildEmbeddingProfileId(config.profile);
144
+ this.clock = deps.clock;
145
+ this.random = deps.random;
146
+ this.cache = deps.cache ?? new InMemoryEmbeddingCache();
147
+ this.sleep = deps.sleep ?? realSleep;
148
+ this.httpClient = new OllamaHttpClient({
149
+ requestTimeoutMs: config.requestTimeoutMs,
150
+ maxConnections: config.maxConnections,
151
+ apiKeyEnv: config.apiKeyEnv,
152
+ fetchImpl: deps.fetchImpl,
153
+ });
154
+ this.backoffConfig = {
155
+ maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
156
+ baseDelayMs: config.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS,
157
+ maxDelayMs: config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS,
158
+ };
159
+ }
160
+
161
+ public async ensureReady(options?: PortCallOptions): Promise<EmbeddingReadinessV1> {
162
+ return guarded(options?.signal, async () => {
163
+ await this.ensureReadyOnce(options?.signal);
164
+ // Non-null: ensureReadyOnce either already resolved it or just did.
165
+ return this.cachedReadiness as EmbeddingReadinessV1;
166
+ });
167
+ }
168
+
169
+ public async embedDocuments(
170
+ preparedTexts: string[],
171
+ options?: PortCallOptions & { onBatchProgress?: (progress: EmbeddingBatchProgressV1) => void },
172
+ ): Promise<EmbeddingBatchResultV1> {
173
+ return guarded(options?.signal, async () => {
174
+ if (preparedTexts.length === 0) {
175
+ return { vectors: [], truncated: [] };
176
+ }
177
+ await this.ensureReadyOnce(options?.signal);
178
+ return this.embedTexts(preparedTexts, options?.signal, options?.onBatchProgress);
179
+ });
180
+ }
181
+
182
+ public async embedQuery(preparedText: string, options?: PortCallOptions): Promise<number[]> {
183
+ return guarded(options?.signal, async () => {
184
+ await this.ensureReadyOnce(options?.signal);
185
+ const { vectors } = await this.embedTexts([preparedText], options?.signal);
186
+ return vectors[0] as number[];
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Runs the readiness sequence exactly once per instance; concurrent
192
+ * callers (including through model-pull, spec.md §9.1 step 4) await the
193
+ * SAME run rather than each starting their own (WP-78 fehlerklasse: the
194
+ * same in-flight-dedup requirement applies here, not just to `/api/embed`).
195
+ *
196
+ * The shared run itself is started with NO caller's `signal` (it is
197
+ * intentionally not tied to whichever caller happened to trigger it) —
198
+ * `raceWithSignal` below still lets THIS caller's own signal reject its
199
+ * own wait promptly, without cancelling the readiness sequence still
200
+ * running for other, still-waiting callers.
201
+ */
202
+ private async ensureReadyOnce(signal: AbortSignal | undefined): Promise<void> {
203
+ if (this.cachedReadiness) {
204
+ return;
205
+ }
206
+ if (!this.readyPromise) {
207
+ this.readyPromise = this.runReadiness().catch((error: unknown) => {
208
+ // A failed attempt must not be cached — the next call retries from scratch.
209
+ this.readyPromise = undefined;
210
+ throw error;
211
+ });
212
+ }
213
+ await raceWithSignal(this.readyPromise, signal);
214
+ }
215
+
216
+ private async runReadiness(): Promise<void> {
217
+ const outcome = await runReadinessSequence(
218
+ this.config,
219
+ {
220
+ httpClient: this.httpClient,
221
+ clock: this.clock,
222
+ random: this.random,
223
+ sleep: this.sleep,
224
+ backoffConfig: this.backoffConfig,
225
+ },
226
+ undefined,
227
+ );
228
+ this.cachedReadiness = outcome.readiness;
229
+ this.resolvedEndpointUrl = outcome.endpointUrl;
230
+ }
231
+
232
+ private async embedTexts(
233
+ texts: string[],
234
+ signal: AbortSignal | undefined,
235
+ onBatchProgress?: (progress: EmbeddingBatchProgressV1) => void,
236
+ ): Promise<EmbeddingBatchResultV1> {
237
+ const endpointUrl = this.resolvedEndpointUrl;
238
+ if (!endpointUrl) {
239
+ throw embeddingUnavailableError(
240
+ "Ollama embedding provider is not ready (no resolved endpoint).",
241
+ {
242
+ retryable: false,
243
+ },
244
+ );
245
+ }
246
+ const slices = chunkIntoSlices(texts, Math.max(1, this.config.batchSize));
247
+ const batchCount = slices.length;
248
+ let embeddedCount = 0;
249
+
250
+ const sliceResults = await mapWithConcurrency(
251
+ slices,
252
+ this.config.maxConnections,
253
+ async (slice, sliceIndex) => {
254
+ signal?.throwIfAborted();
255
+ const result = await this.embedSlice(slice, endpointUrl, signal);
256
+ embeddedCount += slice.length;
257
+ onBatchProgress?.({
258
+ batchIndex: sliceIndex,
259
+ batchCount,
260
+ embeddedCount,
261
+ totalCount: texts.length,
262
+ });
263
+ return result;
264
+ },
265
+ );
266
+
267
+ const vectors = sliceResults.flatMap((result) => result.vectors);
268
+ const truncated = sliceResults.flatMap((result) => result.truncated);
269
+ return { vectors, truncated };
270
+ }
271
+
272
+ private async embedSlice(
273
+ texts: string[],
274
+ endpointUrl: string,
275
+ signal: AbortSignal | undefined,
276
+ ): Promise<EmbeddingBatchResultV1> {
277
+ const keys: EmbeddingCacheKey[] = texts.map((text) => ({
278
+ embeddingProfileId: this.profileId,
279
+ preparedTextHash: sha256Hex(text),
280
+ }));
281
+ const keyStrings = keys.map(cacheKeyString);
282
+ const cached = await Promise.all(keys.map((key) => this.cache.get(key, { signal })));
283
+
284
+ const vectors = new Array<number[]>(texts.length);
285
+ const truncated = new Array<boolean>(texts.length);
286
+
287
+ // Group the still-missing positions by cache key: (a) the SAME prepared
288
+ // text appearing more than once in this slice must be embedded only
289
+ // once (spec.md §9.4, WP-78 "Batch-interne Deduplizierung"), and (b) a
290
+ // key already being fetched by ANOTHER concurrent call is joined below
291
+ // instead of triggering a second `/api/embed` request.
292
+ const missGroups = new Map<string, number[]>();
293
+ for (let index = 0; index < texts.length; index += 1) {
294
+ const hit = cached[index];
295
+ if (hit) {
296
+ vectors[index] = hit.vector;
297
+ truncated[index] = hit.truncated;
298
+ continue;
299
+ }
300
+ const keyString = keyStrings[index] as string;
301
+ const group = missGroups.get(keyString);
302
+ if (group) {
303
+ group.push(index);
304
+ } else {
305
+ missGroups.set(keyString, [index]);
306
+ }
307
+ }
308
+
309
+ if (missGroups.size === 0) {
310
+ return { vectors, truncated };
311
+ }
312
+ signal?.throwIfAborted();
313
+
314
+ // Claim/join in-flight fetches. This loop MUST NOT `await` anything: two
315
+ // concurrent `embedSlice` calls that both read a cache miss for the same
316
+ // key resume as two separate, atomically-run continuations (JS never
317
+ // interleaves them mid-loop), so whichever runs this claiming loop first
318
+ // wins the key and the other necessarily observes and joins it instead
319
+ // of starting its own fetch (WP-78 "Nebenläufigkeit").
320
+ const toFetch: ClaimedEmbedItem[] = [];
321
+ const pending: { indexes: number[]; promise: Promise<EmbeddingCacheEntry> }[] = [];
322
+ for (const [keyString, indexes] of missGroups) {
323
+ const existing = this.inFlightEmbeds.get(keyString);
324
+ if (existing) {
325
+ pending.push({ indexes, promise: existing });
326
+ continue;
327
+ }
328
+ const deferred = createDeferred<EmbeddingCacheEntry>();
329
+ this.inFlightEmbeds.register(keyString, deferred.promise);
330
+ const firstIndex = indexes[0] as number;
331
+ toFetch.push({
332
+ keyString,
333
+ key: keys[firstIndex] as EmbeddingCacheKey,
334
+ text: texts[firstIndex] as string,
335
+ deferred,
336
+ });
337
+ pending.push({ indexes, promise: deferred.promise });
338
+ }
339
+
340
+ if (toFetch.length > 0) {
341
+ // Deliberately not awaited here: the underlying request must survive
342
+ // (and keep serving every joined caller) even if THIS caller — the
343
+ // one that happened to claim the keys — is the one whose own signal
344
+ // aborts. Errors are routed to each `deferred` inside the method, so
345
+ // this can never become an unhandled rejection.
346
+ void this.fetchAndSettle(toFetch, endpointUrl);
347
+ }
348
+
349
+ await Promise.all(
350
+ pending.map(async ({ indexes, promise }) => {
351
+ // `raceWithSignal`, not `promise` directly: THIS call's own signal
352
+ // must be able to reject THIS call without cancelling the shared
353
+ // fetch for any other caller still awaiting the same key.
354
+ const entry = await raceWithSignal(promise, signal);
355
+ for (const index of indexes) {
356
+ vectors[index] = entry.vector;
357
+ truncated[index] = entry.truncated;
358
+ }
359
+ }),
360
+ );
361
+
362
+ return { vectors, truncated };
363
+ }
364
+
365
+ /**
366
+ * Fetches every text in `items` in a SINGLE `/api/embed` request and
367
+ * settles each item's `deferred` with its own resulting cache entry, or
368
+ * rejects all of them on failure — which also frees their in-flight slots
369
+ * (`InFlightRegistry.register`'s cleanup), so the next call retries from
370
+ * scratch rather than being stuck on a broken promise (WP-78).
371
+ *
372
+ * Vor dem Absetzen des Requests wird der Cache ein ZWEITES Mal gelesen
373
+ * (WP-117, siehe {@link revalidateBeforeFetch}): die Miss-Entscheidung in
374
+ * `embedSlice` kann veraltet sein, sobald `cache.get()` verzögert auflöst.
375
+ *
376
+ * Intentionally takes no `AbortSignal`: this operation is shared by
377
+ * however many callers joined it, so it is not tied to any single one of
378
+ * their lifecycles (see `embedSlice`/`raceWithSignal`).
379
+ */
380
+ private async fetchAndSettle(items: ClaimedEmbedItem[], endpointUrl: string): Promise<void> {
381
+ try {
382
+ // WP-117: erst jetzt, mit den bereits beanspruchten In-flight-Plätzen
383
+ // in der Hand, steht fest, was wirklich über die Leitung muss.
384
+ const outstanding = await this.revalidateBeforeFetch(items);
385
+ if (outstanding.length === 0) {
386
+ return;
387
+ }
388
+ const texts = outstanding.map((item) => item.text);
389
+ const response = await withRetry(
390
+ () => embed(this.httpClient, endpointUrl, this.profile.model, texts),
391
+ this.backoffConfig,
392
+ { random: this.random, sleep: this.sleep },
393
+ { isRetryable: isRetryableEmbedError },
394
+ );
395
+ if (response.embeddings.length !== outstanding.length) {
396
+ throw embeddingUnavailableError(
397
+ `Ollama /api/embed returned ${response.embeddings.length} vectors for ${outstanding.length} inputs.`,
398
+ { retryable: false },
399
+ );
400
+ }
401
+ for (let j = 0; j < outstanding.length; j += 1) {
402
+ const item = outstanding[j] as ClaimedEmbedItem;
403
+ const vector = response.embeddings[j] as number[];
404
+ assertVectorDimension(vector, this.profile.dimensions, {
405
+ endpoint: endpointUrl,
406
+ model: this.profile.model,
407
+ stage: "embed",
408
+ });
409
+ const isTruncated = wouldProviderTruncate(item.text, this.config.contextLength);
410
+ const entry: EmbeddingCacheEntry = { vector, truncated: isTruncated };
411
+ await this.cache.set(item.key, entry);
412
+ item.deferred.resolve(entry);
413
+ }
414
+ } catch (error) {
415
+ // Bewusst über `items` und nicht über `outstanding`: schlägt schon die
416
+ // Revalidierung fehl, ist `outstanding` gar nicht bestimmt. Ein bereits
417
+ // aus dem Cache aufgelöstes Deferred ignoriert das spätere `reject`
418
+ // (die erste Festlegung gewinnt), ein noch offenes wird sicher
419
+ // abgewiesen — kein Aufrufer bleibt hängen.
420
+ for (const item of items) {
421
+ item.deferred.reject(error);
422
+ }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Zweite Cache-Prüfung unmittelbar vor dem Request (WP-117).
428
+ *
429
+ * `embedSlice` trifft die Miss-/Join-Entscheidung EINMAL, aus einem
430
+ * einzigen `Promise.all` über `cache.get()`. Ein Cache, dessen `get()` den
431
+ * Zustand zum Aufrufzeitpunkt liest, aber erst später auflöst — also jeder
432
+ * netzgebundene Cache und damit genau der vorgesehene Erweiterungspunkt
433
+ * für einen persistenten Adapter —, öffnet ein Fenster: Aufrufer A kann
434
+ * zwischen der Anfrage und dem Auflösen von B's `get()` vollständig fertig
435
+ * werden, seinen Eintrag schreiben und seinen In-flight-Platz wieder
436
+ * freigeben. B fände dann weder Cache-Treffer noch In-flight-Eintrag und
437
+ * würde denselben Text ein zweites Mal einbetten.
438
+ *
439
+ * Diese Prüfung schließt das Fenster, weil sie STRIKT NACH der Beanspruchung
440
+ * des In-flight-Platzes liegt: der Platz wird erst freigegeben, nachdem
441
+ * `cache.set()` aufgelöst hat (siehe unten), also sieht ein `get()`, das
442
+ * danach abgesetzt wird, den geschriebenen Eintrag.
443
+ *
444
+ * Kein Deadlock und keine Wiederholungsschleife: genau EIN zusätzliches
445
+ * `cache.get()` je beanspruchtem Schlüssel, kein Warten auf fremde
446
+ * Deferreds (die eigenen Plätze hält dieser Aufruf selbst, ein anderer
447
+ * Aufrufer kann sie also nicht beanspruchen), und jedes Deferred wird auf
448
+ * jedem Pfad festgelegt — aus dem Cache, aus der Antwort oder per
449
+ * `reject` im `catch`.
450
+ *
451
+ * Kein `AbortSignal`: dieser Lauf gehört keinem einzelnen Aufrufer
452
+ * (dieselbe Begründung wie bei {@link fetchAndSettle}).
453
+ *
454
+ * Ein Fehler des Caches wird NICHT verschluckt (E4), sondern nach oben
455
+ * gereicht und weist damit die beteiligten Deferreds ab — dasselbe
456
+ * sichtbare Verhalten wie bei einem Fehlschlag der ERSTEN Cache-Lesung in
457
+ * `embedSlice`. Preis dieser Prüfung: eine zusätzliche Cache-Lesung je
458
+ * echtem Miss (dokumentiert in der README).
459
+ */
460
+ private async revalidateBeforeFetch(items: ClaimedEmbedItem[]): Promise<ClaimedEmbedItem[]> {
461
+ // Erst alle Lesungen abschließen, dann festlegen: so ist bei einem Fehler
462
+ // noch kein Deferred aufgelöst und der `catch` in `fetchAndSettle` weist
463
+ // wirklich alle ab.
464
+ const lateHits = await Promise.all(items.map(async (item) => this.cache.get(item.key)));
465
+ const outstanding: ClaimedEmbedItem[] = [];
466
+ for (let index = 0; index < items.length; index += 1) {
467
+ const item = items[index] as ClaimedEmbedItem;
468
+ const hit = lateHits[index];
469
+ if (hit) {
470
+ item.deferred.resolve(hit);
471
+ continue;
472
+ }
473
+ outstanding.push(item);
474
+ }
475
+ return outstanding;
476
+ }
477
+ }