@untangle-code/embeddings-ollama 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/NOTICE +90 -0
- package/README.md +187 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/backoff.d.ts +43 -0
- package/dist/backoff.d.ts.map +1 -0
- package/dist/backoff.js +38 -0
- package/dist/backoff.js.map +1 -0
- package/dist/cache.d.ts +17 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +17 -0
- package/dist/cache.js.map +1 -0
- package/dist/concurrency.d.ts +11 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +31 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/endpoint.d.ts +16 -0
- package/dist/endpoint.d.ts.map +1 -0
- package/dist/endpoint.js +32 -0
- package/dist/endpoint.js.map +1 -0
- package/dist/http.d.ts +54 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +136 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/inflight.d.ts +49 -0
- package/dist/inflight.d.ts.map +1 -0
- package/dist/inflight.js +87 -0
- package/dist/inflight.js.map +1 -0
- package/dist/ollama-api.d.ts +45 -0
- package/dist/ollama-api.d.ts.map +1 -0
- package/dist/ollama-api.js +40 -0
- package/dist/ollama-api.js.map +1 -0
- package/dist/provider.d.ts +111 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +374 -0
- package/dist/provider.js.map +1 -0
- package/dist/readiness.d.ts +31 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +105 -0
- package/dist/readiness.js.map +1 -0
- package/dist/sleep.d.ts +8 -0
- package/dist/sleep.d.ts.map +1 -0
- package/dist/sleep.js +20 -0
- package/dist/sleep.js.map +1 -0
- package/dist/truncation.d.ts +30 -0
- package/dist/truncation.d.ts.map +1 -0
- package/dist/truncation.js +39 -0
- package/dist/truncation.js.map +1 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +2 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +16 -0
- package/dist/validation.js.map +1 -0
- package/package.json +62 -3
- package/src/backoff.ts +73 -0
- package/src/cache.ts +30 -0
- package/src/concurrency.ts +37 -0
- package/src/endpoint.ts +31 -0
- package/src/http.ts +168 -0
- package/src/index.ts +21 -0
- package/src/inflight.ts +102 -0
- package/src/ollama-api.ts +93 -0
- package/src/provider.ts +477 -0
- package/src/readiness.ts +203 -0
- package/src/sleep.ts +27 -0
- package/src/truncation.ts +41 -0
- package/src/types.ts +132 -0
- package/src/validation.ts +23 -0
package/src/inflight.ts
ADDED
|
@@ -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
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -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
|
+
}
|