@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.
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,203 @@
1
+ /**
2
+ * Readiness sequence (spec.md §9.1), run exactly in the spec's order:
3
+ *
4
+ * 1. determine the effective endpoint;
5
+ * 2. check reachability;
6
+ * 3. check model availability;
7
+ * 4. pull the model in managed mode if needed;
8
+ * 5. produce a probe embedding;
9
+ * 6. validate the vector dimension;
10
+ * 7. cache profile and probe timestamp (done by the caller, `provider.ts`,
11
+ * which owns the cached `EmbeddingReadinessV1`).
12
+ */
13
+ import type { Clock, EmbeddingReadinessV1, Random } from "@untangle-code/domain";
14
+ import { configInvalidError, embeddingUnavailableError } from "@untangle-code/observability";
15
+
16
+ import type { BackoffConfig } from "./backoff.js";
17
+ import { withRetry } from "./backoff.js";
18
+ import { redactEndpointUrl } from "./endpoint.js";
19
+ import { isRequestCancellation, OllamaHttpError, type OllamaHttpClient } from "./http.js";
20
+ import {
21
+ embed,
22
+ listTags,
23
+ pullModel,
24
+ tagsContainModel,
25
+ type OllamaTagsResponseV1,
26
+ } from "./ollama-api.js";
27
+ import type { SleepFn, OllamaEmbeddingProviderConfig } from "./types.js";
28
+ import { DEFAULT_MANAGED_URL, DEFAULT_NATIVE_PROBE_URL } from "./types.js";
29
+ import { assertVectorDimension } from "./validation.js";
30
+
31
+ export const READINESS_PROBE_TEXT = "untangle embedding readiness probe";
32
+
33
+ export interface ReadinessDeps {
34
+ httpClient: OllamaHttpClient;
35
+ clock: Clock;
36
+ random: Random;
37
+ sleep: SleepFn;
38
+ backoffConfig: BackoffConfig;
39
+ }
40
+
41
+ export interface ReadinessOutcome {
42
+ readiness: EmbeddingReadinessV1;
43
+ /** The resolved, non-redacted endpoint — used internally for subsequent embed calls. */
44
+ endpointUrl: string;
45
+ }
46
+
47
+ function isRetryableOllamaError(error: unknown): boolean {
48
+ return error instanceof OllamaHttpError && error.retryable;
49
+ }
50
+
51
+ async function requestWithRetry<T>(
52
+ fn: () => Promise<T>,
53
+ deps: ReadinessDeps,
54
+ signal?: AbortSignal,
55
+ ): Promise<T> {
56
+ return withRetry(
57
+ fn,
58
+ deps.backoffConfig,
59
+ { random: deps.random, sleep: deps.sleep },
60
+ {
61
+ signal,
62
+ isRetryable: isRetryableOllamaError,
63
+ },
64
+ );
65
+ }
66
+
67
+ /** `GET /api/tags`, treating any failure as "not reachable" — EXCEPT cancellation, which propagates. */
68
+ async function tryListTags(
69
+ url: string,
70
+ deps: ReadinessDeps,
71
+ signal?: AbortSignal,
72
+ ): Promise<OllamaTagsResponseV1 | null> {
73
+ try {
74
+ return await requestWithRetry(() => listTags(deps.httpClient, url, signal), deps, signal);
75
+ } catch (error) {
76
+ if (isRequestCancellation(error, signal)) {
77
+ throw error;
78
+ }
79
+ return null;
80
+ }
81
+ }
82
+
83
+ async function checkReachable(
84
+ url: string,
85
+ deps: ReadinessDeps,
86
+ signal?: AbortSignal,
87
+ ): Promise<OllamaTagsResponseV1> {
88
+ const tags = await tryListTags(url, deps, signal);
89
+ if (!tags) {
90
+ throw embeddingUnavailableError(`Ollama endpoint ${redactEndpointUrl(url)} is not reachable.`, {
91
+ retryable: true,
92
+ context: { endpoint: redactEndpointUrl(url) },
93
+ });
94
+ }
95
+ return tags;
96
+ }
97
+
98
+ interface ResolvedEndpoint {
99
+ url: string;
100
+ /** Whether this endpoint is one Untangle manages (spec.md §3.1) — safe to auto-pull models on it. */
101
+ managed: boolean;
102
+ tags: OllamaTagsResponseV1;
103
+ }
104
+
105
+ /** spec.md §9.1 steps 1-2: determine the effective endpoint and confirm it is reachable. */
106
+ async function resolveEndpoint(
107
+ config: OllamaEmbeddingProviderConfig,
108
+ deps: ReadinessDeps,
109
+ signal?: AbortSignal,
110
+ ): Promise<ResolvedEndpoint> {
111
+ if (config.mode === "external") {
112
+ if (!config.url) {
113
+ throw configInvalidError(
114
+ 'EmbeddingConfig.url is required when embeddings.mode is "external" (spec.md §4.5).',
115
+ { retryable: false },
116
+ );
117
+ }
118
+ return {
119
+ url: config.url,
120
+ managed: false,
121
+ tags: await checkReachable(config.url, deps, signal),
122
+ };
123
+ }
124
+ if (config.mode === "managed") {
125
+ const url = config.url ?? config.managedUrl ?? DEFAULT_MANAGED_URL;
126
+ return { url, managed: true, tags: await checkReachable(url, deps, signal) };
127
+ }
128
+ // "auto" (spec.md §3.1): native probe first, managed endpoint as fallback.
129
+ const nativeUrl = config.nativeProbeUrl ?? DEFAULT_NATIVE_PROBE_URL;
130
+ const nativeTags = await tryListTags(nativeUrl, deps, signal);
131
+ if (nativeTags) {
132
+ return { url: nativeUrl, managed: false, tags: nativeTags };
133
+ }
134
+ const managedUrl = config.url ?? config.managedUrl ?? DEFAULT_MANAGED_URL;
135
+ return { url: managedUrl, managed: true, tags: await checkReachable(managedUrl, deps, signal) };
136
+ }
137
+
138
+ export async function runReadinessSequence(
139
+ config: OllamaEmbeddingProviderConfig,
140
+ deps: ReadinessDeps,
141
+ signal?: AbortSignal,
142
+ ): Promise<ReadinessOutcome> {
143
+ const { url, managed, tags } = await resolveEndpoint(config, deps, signal);
144
+ const modelName = config.profile.model;
145
+
146
+ // Step 3: model availability.
147
+ let available = tagsContainModel(tags.models, modelName);
148
+
149
+ // Step 4: pull in managed mode if needed (spec.md §3.2 forbids touching an external endpoint).
150
+ if (!available) {
151
+ if (!managed) {
152
+ throw embeddingUnavailableError(
153
+ `Ollama model "${modelName}" is not available at ${redactEndpointUrl(url)}, and this endpoint is not managed by Untangle — pull it manually (spec.md §3.2).`,
154
+ { retryable: false, context: { endpoint: redactEndpointUrl(url), model: modelName } },
155
+ );
156
+ }
157
+ await requestWithRetry(() => pullModel(deps.httpClient, url, modelName, signal), deps, signal);
158
+ const refreshed = await requestWithRetry(
159
+ () => listTags(deps.httpClient, url, signal),
160
+ deps,
161
+ signal,
162
+ );
163
+ available = tagsContainModel(refreshed.models, modelName);
164
+ if (!available) {
165
+ throw embeddingUnavailableError(
166
+ `Ollama model "${modelName}" was still not available after pulling it from ${redactEndpointUrl(url)}.`,
167
+ { retryable: false, context: { endpoint: redactEndpointUrl(url), model: modelName } },
168
+ );
169
+ }
170
+ }
171
+
172
+ // Step 5: probe embedding.
173
+ const probeResponse = await requestWithRetry(
174
+ () => embed(deps.httpClient, url, modelName, [READINESS_PROBE_TEXT], signal),
175
+ deps,
176
+ signal,
177
+ );
178
+ const probeVector = probeResponse.embeddings[0];
179
+ if (!probeVector) {
180
+ throw embeddingUnavailableError(
181
+ `Ollama probe embedding returned no vector for model "${modelName}".`,
182
+ { retryable: false, context: { endpoint: redactEndpointUrl(url), model: modelName } },
183
+ );
184
+ }
185
+
186
+ // Step 6: dimension validation (must fail BEFORE the first upsert, spec.md §4.8).
187
+ assertVectorDimension(probeVector, config.profile.dimensions, {
188
+ endpoint: redactEndpointUrl(url),
189
+ model: modelName,
190
+ stage: "readiness_probe",
191
+ });
192
+
193
+ // Step 7: profile + probe timestamp are cached by the caller from this result.
194
+ return {
195
+ readiness: {
196
+ endpoint: redactEndpointUrl(url),
197
+ model: modelName,
198
+ dimensions: probeVector.length,
199
+ probedAt: deps.clock.nowIso(),
200
+ },
201
+ endpointUrl: url,
202
+ };
203
+ }
package/src/sleep.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Real, abortable {@link SleepFn} implementation. The only production
3
+ * implementation of the port declared in `types.ts`; tests inject a fake
4
+ * that resolves immediately instead of waiting for real time to pass.
5
+ */
6
+ import type { SleepFn } from "./types.js";
7
+
8
+ export const realSleep: SleepFn = (ms, signal) =>
9
+ new Promise<void>((resolve, reject) => {
10
+ if (signal?.aborted) {
11
+ reject(signal.reason);
12
+ return;
13
+ }
14
+ const timer = setTimeout(() => {
15
+ cleanup();
16
+ resolve();
17
+ }, ms);
18
+ const onAbort = (): void => {
19
+ cleanup();
20
+ reject(signal?.reason);
21
+ };
22
+ function cleanup(): void {
23
+ clearTimeout(timer);
24
+ signal?.removeEventListener("abort", onAbort);
25
+ }
26
+ signal?.addEventListener("abort", onAbort, { once: true });
27
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Truncation guard (spec.md §9.3).
3
+ *
4
+ * Ollama's `/api/embed` truncates silently to the model's context window by
5
+ * default (`truncate: true`) and does not report, per input, whether
6
+ * truncation actually happened. The chunker (a separate package) is already
7
+ * responsible for keeping prepared text within budget in the normal case;
8
+ * this guard exists purely to catch the exceptional case where it did not
9
+ * and flag the resulting point honestly rather than silently accepting a
10
+ * vector computed from a truncated text (vision.md 8.4 "no silent data
11
+ * loss").
12
+ *
13
+ * Detection is a conservative, offline token estimate compared against the
14
+ * configured `contextLength` — the same trade-off `@untangle-code/chunker`
15
+ * makes for its own budget checks (over-estimating real BPE token counts is
16
+ * far safer than under-estimating, which would hide a real truncation).
17
+ * The estimator is intentionally duplicated rather than imported: chunker
18
+ * uses it to fit a budget ahead of time, this module uses it to flag a
19
+ * budget miss after the fact, and importing chunker here would add a
20
+ * runtime dependency between packages that otherwise have no reason to
21
+ * know about each other (rule 3 spirit).
22
+ */
23
+
24
+ /** Conservative average characters per token — deliberately lower than the ~4 real average. */
25
+ const CHARS_PER_TOKEN = 3;
26
+
27
+ /** Estimates a conservative (over-)approximation of `text`'s token count. */
28
+ export function estimateTokenCount(text: string): number {
29
+ if (text.length === 0) {
30
+ return 0;
31
+ }
32
+ return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
33
+ }
34
+
35
+ /**
36
+ * True when `text`'s estimated token count exceeds `contextLength` — the
37
+ * point at which Ollama's provider-side truncation actually applies.
38
+ */
39
+ export function wouldProviderTruncate(text: string, contextLength: number): boolean {
40
+ return estimateTokenCount(text) > contextLength;
41
+ }
package/src/types.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Local types for the Ollama `EmbeddingProvider` adapter (spec.md §9, §4.5;
3
+ * architecture.md §6.8).
4
+ */
5
+ import type { EmbeddingProfile } from "@untangle-code/domain";
6
+
7
+ /** Endpoint resolution mode (spec.md §4.5 `EmbeddingConfig.mode`). */
8
+ export type OllamaEndpointMode = "auto" | "managed" | "external";
9
+
10
+ /**
11
+ * Operational settings for one {@link OllamaEmbeddingProvider} instance.
12
+ *
13
+ * `profile` carries exactly the eight `EmbeddingProfile` fields that feed
14
+ * `embedding_profile_id` (spec.md §5.7) — everything else here is
15
+ * operational (endpoints, batching, timeouts) and deliberately NOT part of
16
+ * the profile, matching domain's `EmbeddingProfile` doc comment. Callers
17
+ * (typically `@untangle-code/config`'s `deriveEmbeddingProfile`) are
18
+ * responsible for assembling `profile` from the user's `EmbeddingConfig`;
19
+ * this package only consumes the result, keeping it decoupled from the
20
+ * config package (rule 3: no dependency on a package built in the same
21
+ * wave).
22
+ */
23
+ export interface OllamaEmbeddingProviderConfig {
24
+ profile: EmbeddingProfile;
25
+ mode: OllamaEndpointMode;
26
+ /**
27
+ * Explicit endpoint override. Required when `mode` is `"external"`.
28
+ * When `mode` is `"managed"`, overrides {@link managedUrl}. Ignored in
29
+ * `"auto"` mode's native-probe step, but used as the managed fallback
30
+ * there too when set.
31
+ */
32
+ // Optional fields below spell out `| undefined` explicitly (rather than
33
+ // just `?:`) so callers under `exactOptionalPropertyTypes` — including
34
+ // this package's own test helper, which builds a config by spreading
35
+ // caller overrides over defaults — can assign `undefined` to force a
36
+ // field genuinely absent instead of merely "not overridden".
37
+ url?: string | undefined;
38
+ /** Name of the env var holding a bearer token; the token itself is never logged (spec.md §24). */
39
+ apiKeyEnv?: string | undefined;
40
+ /** Model context window in tokens, used only for the truncation guard (spec.md §9.3). */
41
+ contextLength: number;
42
+ /** Number of prepared texts sent per Ollama `/api/embed` request. */
43
+ batchSize: number;
44
+ /** Upper bound on concurrently in-flight HTTP requests to Ollama. */
45
+ maxConnections: number;
46
+ /** Per-request timeout in milliseconds. */
47
+ requestTimeoutMs: number;
48
+ /** Native Ollama probe URL for `"auto"` mode (spec.md §3.1 default). */
49
+ nativeProbeUrl?: string | undefined;
50
+ /** Managed-container endpoint default (spec.md §3.1 default port 11435). */
51
+ managedUrl?: string | undefined;
52
+ /** Maximum retry attempts per request before a transient error is treated as permanent. */
53
+ maxRetries?: number | undefined;
54
+ /** Base delay for exponential backoff (spec.md §9.2). */
55
+ baseBackoffMs?: number | undefined;
56
+ /** Backoff delay cap. */
57
+ maxBackoffMs?: number | undefined;
58
+ }
59
+
60
+ /** spec.md §3.1 defaults. */
61
+ export const DEFAULT_NATIVE_PROBE_URL = "http://127.0.0.1:11434";
62
+ export const DEFAULT_MANAGED_URL = "http://127.0.0.1:11435";
63
+
64
+ export const DEFAULT_MAX_RETRIES = 5;
65
+ export const DEFAULT_BASE_BACKOFF_MS = 250;
66
+ export const DEFAULT_MAX_BACKOFF_MS = 8000;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Embedding cache (spec.md §9.4)
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Cache key per spec.md §9.4: `embedding_profile_id + SHA256(prepared_text)`.
74
+ * Kept as a structured object (rather than a pre-joined string) so a
75
+ * persistent implementation is free to choose its own storage shape.
76
+ */
77
+ export interface EmbeddingCacheKey {
78
+ embeddingProfileId: string;
79
+ /** `SHA256(prepared_text)`, hex-encoded. */
80
+ preparedTextHash: string;
81
+ }
82
+
83
+ export interface EmbeddingCacheEntry {
84
+ vector: number[];
85
+ /** Whether provider-side truncation applied when this vector was produced (spec.md §9.3). */
86
+ truncated: boolean;
87
+ }
88
+
89
+ /**
90
+ * Narrow, storage-agnostic embedding cache port.
91
+ *
92
+ * NOT a `@untangle-code/domain` port — the task that produced this adapter
93
+ * defines it here deliberately so it can be exercised immediately, but this
94
+ * interface conceptually belongs in domain's `ports.ts` next to
95
+ * `EmbeddingProvider` so that `storage-local` (or any other persistent
96
+ * implementation) can implement a shared, versioned contract instead of a
97
+ * package-local one. Flagged in the WP-10 report's `openIssues` for a
98
+ * follow-up move.
99
+ */
100
+ export interface EmbeddingCachePort {
101
+ get(
102
+ key: EmbeddingCacheKey,
103
+ options?: { signal?: AbortSignal | undefined },
104
+ ): Promise<EmbeddingCacheEntry | null>;
105
+ set(
106
+ key: EmbeddingCacheKey,
107
+ entry: EmbeddingCacheEntry,
108
+ options?: { signal?: AbortSignal | undefined },
109
+ ): Promise<void>;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Sleep (backoff delay)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Injectable delay primitive for backoff waits (spec.md §9.2). Not a
118
+ * `@untangle-code/domain` port (domain has no scheduling port, only `Clock`
119
+ * for reading "now") — tests inject a fake that resolves instantly while
120
+ * still recording the requested delays, so the exponential-backoff schedule
121
+ * is asserted without the test suite actually waiting.
122
+ */
123
+ export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
124
+
125
+ /** Progress reported once per network batch (spec.md §9.2 "Fortschritt MUSS pro Batch aktualisiert werden"). */
126
+ export interface EmbeddingBatchProgressV1 {
127
+ batchIndex: number;
128
+ batchCount: number;
129
+ /** Texts embedded so far across all batches of this call, including cache hits. */
130
+ embeddedCount: number;
131
+ totalCount: number;
132
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Vector-dimension validation (spec.md §9.1 step 6, §4.8 "Falsch
3
+ * konfigurierte Dimensionen MÜSSEN vor dem ersten Index-Upsert erkannt
4
+ * werden"). Applied both to the readiness probe vector and to every
5
+ * subsequent batch result — defense in depth, not just a one-time check.
6
+ */
7
+ import { embeddingDimensionMismatchError } from "@untangle-code/observability";
8
+
9
+ export function assertVectorDimension(
10
+ vector: number[],
11
+ expectedDimensions: number,
12
+ context: Record<string, unknown>,
13
+ ): void {
14
+ if (vector.length !== expectedDimensions) {
15
+ throw embeddingDimensionMismatchError(
16
+ `Ollama returned a ${vector.length}-dimensional vector but the embedding profile expects ${expectedDimensions} dimensions.`,
17
+ {
18
+ retryable: false,
19
+ context: { ...context, actualDimensions: vector.length, expectedDimensions },
20
+ },
21
+ );
22
+ }
23
+ }