@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,39 @@
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
+ /** Conservative average characters per token — deliberately lower than the ~4 real average. */
24
+ const CHARS_PER_TOKEN = 3;
25
+ /** Estimates a conservative (over-)approximation of `text`'s token count. */
26
+ export function estimateTokenCount(text) {
27
+ if (text.length === 0) {
28
+ return 0;
29
+ }
30
+ return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
31
+ }
32
+ /**
33
+ * True when `text`'s estimated token count exceeds `contextLength` — the
34
+ * point at which Ollama's provider-side truncation actually applies.
35
+ */
36
+ export function wouldProviderTruncate(text, contextLength) {
37
+ return estimateTokenCount(text) > contextLength;
38
+ }
39
+ //# sourceMappingURL=truncation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"truncation.js","sourceRoot":"","sources":["../src/truncation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,+FAA+F;AAC/F,MAAM,eAAe,GAAG,CAAC,CAAC;AAE1B,6EAA6E;AAC7E,MAAM,UAAU,kBAAkB,CAAC,IAAY;IAC7C,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACtB,OAAO,CAAC,CAAC;IACX,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,eAAe,CAAC,CAAC,CAAC;AAC/D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,aAAqB;IACvE,OAAO,kBAAkB,CAAC,IAAI,CAAC,GAAG,aAAa,CAAC;AAClD,CAAC"}
@@ -0,0 +1,108 @@
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
+ /** Endpoint resolution mode (spec.md §4.5 `EmbeddingConfig.mode`). */
7
+ export type OllamaEndpointMode = "auto" | "managed" | "external";
8
+ /**
9
+ * Operational settings for one {@link OllamaEmbeddingProvider} instance.
10
+ *
11
+ * `profile` carries exactly the eight `EmbeddingProfile` fields that feed
12
+ * `embedding_profile_id` (spec.md §5.7) — everything else here is
13
+ * operational (endpoints, batching, timeouts) and deliberately NOT part of
14
+ * the profile, matching domain's `EmbeddingProfile` doc comment. Callers
15
+ * (typically `@untangle-code/config`'s `deriveEmbeddingProfile`) are
16
+ * responsible for assembling `profile` from the user's `EmbeddingConfig`;
17
+ * this package only consumes the result, keeping it decoupled from the
18
+ * config package (rule 3: no dependency on a package built in the same
19
+ * wave).
20
+ */
21
+ export interface OllamaEmbeddingProviderConfig {
22
+ profile: EmbeddingProfile;
23
+ mode: OllamaEndpointMode;
24
+ /**
25
+ * Explicit endpoint override. Required when `mode` is `"external"`.
26
+ * When `mode` is `"managed"`, overrides {@link managedUrl}. Ignored in
27
+ * `"auto"` mode's native-probe step, but used as the managed fallback
28
+ * there too when set.
29
+ */
30
+ url?: string | undefined;
31
+ /** Name of the env var holding a bearer token; the token itself is never logged (spec.md §24). */
32
+ apiKeyEnv?: string | undefined;
33
+ /** Model context window in tokens, used only for the truncation guard (spec.md §9.3). */
34
+ contextLength: number;
35
+ /** Number of prepared texts sent per Ollama `/api/embed` request. */
36
+ batchSize: number;
37
+ /** Upper bound on concurrently in-flight HTTP requests to Ollama. */
38
+ maxConnections: number;
39
+ /** Per-request timeout in milliseconds. */
40
+ requestTimeoutMs: number;
41
+ /** Native Ollama probe URL for `"auto"` mode (spec.md §3.1 default). */
42
+ nativeProbeUrl?: string | undefined;
43
+ /** Managed-container endpoint default (spec.md §3.1 default port 11435). */
44
+ managedUrl?: string | undefined;
45
+ /** Maximum retry attempts per request before a transient error is treated as permanent. */
46
+ maxRetries?: number | undefined;
47
+ /** Base delay for exponential backoff (spec.md §9.2). */
48
+ baseBackoffMs?: number | undefined;
49
+ /** Backoff delay cap. */
50
+ maxBackoffMs?: number | undefined;
51
+ }
52
+ /** spec.md §3.1 defaults. */
53
+ export declare const DEFAULT_NATIVE_PROBE_URL = "http://127.0.0.1:11434";
54
+ export declare const DEFAULT_MANAGED_URL = "http://127.0.0.1:11435";
55
+ export declare const DEFAULT_MAX_RETRIES = 5;
56
+ export declare const DEFAULT_BASE_BACKOFF_MS = 250;
57
+ export declare const DEFAULT_MAX_BACKOFF_MS = 8000;
58
+ /**
59
+ * Cache key per spec.md §9.4: `embedding_profile_id + SHA256(prepared_text)`.
60
+ * Kept as a structured object (rather than a pre-joined string) so a
61
+ * persistent implementation is free to choose its own storage shape.
62
+ */
63
+ export interface EmbeddingCacheKey {
64
+ embeddingProfileId: string;
65
+ /** `SHA256(prepared_text)`, hex-encoded. */
66
+ preparedTextHash: string;
67
+ }
68
+ export interface EmbeddingCacheEntry {
69
+ vector: number[];
70
+ /** Whether provider-side truncation applied when this vector was produced (spec.md §9.3). */
71
+ truncated: boolean;
72
+ }
73
+ /**
74
+ * Narrow, storage-agnostic embedding cache port.
75
+ *
76
+ * NOT a `@untangle-code/domain` port — the task that produced this adapter
77
+ * defines it here deliberately so it can be exercised immediately, but this
78
+ * interface conceptually belongs in domain's `ports.ts` next to
79
+ * `EmbeddingProvider` so that `storage-local` (or any other persistent
80
+ * implementation) can implement a shared, versioned contract instead of a
81
+ * package-local one. Flagged in the WP-10 report's `openIssues` for a
82
+ * follow-up move.
83
+ */
84
+ export interface EmbeddingCachePort {
85
+ get(key: EmbeddingCacheKey, options?: {
86
+ signal?: AbortSignal | undefined;
87
+ }): Promise<EmbeddingCacheEntry | null>;
88
+ set(key: EmbeddingCacheKey, entry: EmbeddingCacheEntry, options?: {
89
+ signal?: AbortSignal | undefined;
90
+ }): Promise<void>;
91
+ }
92
+ /**
93
+ * Injectable delay primitive for backoff waits (spec.md §9.2). Not a
94
+ * `@untangle-code/domain` port (domain has no scheduling port, only `Clock`
95
+ * for reading "now") — tests inject a fake that resolves instantly while
96
+ * still recording the requested delays, so the exponential-backoff schedule
97
+ * is asserted without the test suite actually waiting.
98
+ */
99
+ export type SleepFn = (ms: number, signal?: AbortSignal) => Promise<void>;
100
+ /** Progress reported once per network batch (spec.md §9.2 "Fortschritt MUSS pro Batch aktualisiert werden"). */
101
+ export interface EmbeddingBatchProgressV1 {
102
+ batchIndex: number;
103
+ batchCount: number;
104
+ /** Texts embedded so far across all batches of this call, including cache hits. */
105
+ embeddedCount: number;
106
+ totalCount: number;
107
+ }
108
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,sEAAsE;AACtE,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,CAAC;AAEjE;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,6BAA6B;IAC5C,OAAO,EAAE,gBAAgB,CAAC;IAC1B,IAAI,EAAE,kBAAkB,CAAC;IACzB;;;;;OAKG;IAMH,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,kGAAkG;IAClG,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,yFAAyF;IACzF,aAAa,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAC;IAClB,qEAAqE;IACrE,cAAc,EAAE,MAAM,CAAC;IACvB,2CAA2C;IAC3C,gBAAgB,EAAE,MAAM,CAAC;IACzB,wEAAwE;IACxE,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,yDAAyD;IACzD,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,yBAAyB;IACzB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC;AAED,6BAA6B;AAC7B,eAAO,MAAM,wBAAwB,2BAA2B,CAAC;AACjE,eAAO,MAAM,mBAAmB,2BAA2B,CAAC;AAE5D,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,uBAAuB,MAAM,CAAC;AAC3C,eAAO,MAAM,sBAAsB,OAAO,CAAC;AAM3C;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,4CAA4C;IAC5C,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6FAA6F;IAC7F,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,kBAAkB;IACjC,GAAG,CACD,GAAG,EAAE,iBAAiB,EACtB,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;KAAE,GAC7C,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAAC;IACvC,GAAG,CACD,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,mBAAmB,EAC1B,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAA;KAAE,GAC7C,OAAO,CAAC,IAAI,CAAC,CAAC;CAClB;AAMD;;;;;;GAMG;AACH,MAAM,MAAM,OAAO,GAAG,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAE1E,gHAAgH;AAChH,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,mFAAmF;IACnF,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;CACpB"}
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /** spec.md §3.1 defaults. */
2
+ export const DEFAULT_NATIVE_PROBE_URL = "http://127.0.0.1:11434";
3
+ export const DEFAULT_MANAGED_URL = "http://127.0.0.1:11435";
4
+ export const DEFAULT_MAX_RETRIES = 5;
5
+ export const DEFAULT_BASE_BACKOFF_MS = 250;
6
+ export const DEFAULT_MAX_BACKOFF_MS = 8000;
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA2DA,6BAA6B;AAC7B,MAAM,CAAC,MAAM,wBAAwB,GAAG,wBAAwB,CAAC;AACjE,MAAM,CAAC,MAAM,mBAAmB,GAAG,wBAAwB,CAAC;AAE5D,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAC;AACrC,MAAM,CAAC,MAAM,uBAAuB,GAAG,GAAG,CAAC;AAC3C,MAAM,CAAC,MAAM,sBAAsB,GAAG,IAAI,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function assertVectorDimension(vector: number[], expectedDimensions: number, context: Record<string, unknown>): void;
2
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAQA,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,EAAE,EAChB,kBAAkB,EAAE,MAAM,EAC1B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,IAAI,CAUN"}
@@ -0,0 +1,16 @@
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
+ export function assertVectorDimension(vector, expectedDimensions, context) {
9
+ if (vector.length !== expectedDimensions) {
10
+ throw embeddingDimensionMismatchError(`Ollama returned a ${vector.length}-dimensional vector but the embedding profile expects ${expectedDimensions} dimensions.`, {
11
+ retryable: false,
12
+ context: { ...context, actualDimensions: vector.length, expectedDimensions },
13
+ });
14
+ }
15
+ }
16
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,+BAA+B,EAAE,MAAM,8BAA8B,CAAC;AAE/E,MAAM,UAAU,qBAAqB,CACnC,MAAgB,EAChB,kBAA0B,EAC1B,OAAgC;IAEhC,IAAI,MAAM,CAAC,MAAM,KAAK,kBAAkB,EAAE,CAAC;QACzC,MAAM,+BAA+B,CACnC,qBAAqB,MAAM,CAAC,MAAM,yDAAyD,kBAAkB,cAAc,EAC3H;YACE,SAAS,EAAE,KAAK;YAChB,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,EAAE;SAC7E,CACF,CAAC;IACJ,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@untangle-code/embeddings-ollama",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Untangle EmbeddingProvider adapter for Ollama: endpoint auto-detection, readiness, batching with backoff, truncation guard and embedding cache (spec.md §9, architecture.md §6.8).",
7
+ "license": "AGPL-3.0-only",
8
+ "homepage": "https://github.com/lboel/untangle",
9
+ "bugs": {
10
+ "url": "https://github.com/lboel/untangle/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/lboel/untangle.git",
15
+ "directory": "packages/embeddings-ollama"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": "^22.13.0 || >=24.0.0"
22
+ },
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/**/*.test.*",
32
+ "!dist/**/test-helpers.*",
33
+ "!dist/**/test-support.*",
34
+ "!dist/**/test-support/**",
35
+ "!dist/**/testing/**",
36
+ "!dist/**/__tests__/**",
37
+ "src",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/test-helpers.ts",
40
+ "!src/**/test-support.ts",
41
+ "!src/**/test-support/**",
42
+ "!src/**/testing/**",
43
+ "LICENSE",
44
+ "NOTICE",
45
+ "README.md",
46
+ "THIRD_PARTY_LICENSES.md"
47
+ ],
48
+ "dependencies": {
49
+ "@untangle-code/domain": "0.1.1",
50
+ "@untangle-code/observability": "0.1.1"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "22.19.7",
54
+ "typescript": "5.9.3",
55
+ "vitest": "3.2.7",
56
+ "@untangle-code/testkit": "0.1.1"
57
+ },
58
+ "scripts": {
59
+ "build": "node ../../scripts/tsc-build.mjs tsconfig.json",
60
+ "typecheck": "tsc -p tsconfig.typecheck.json",
61
+ "test": "vitest run",
62
+ "test:integration": "vitest run --config vitest.integration.config.ts",
63
+ "clean": "rm -rf dist *.tsbuildinfo"
64
+ }
6
65
  }
package/src/backoff.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Exponential backoff with full jitter (spec.md §9.2).
3
+ *
4
+ * Jitter is drawn from the injected `Random` port, never `Math.random()`
5
+ * (BUILD_PLAN global rule, enforced repo-wide by `eslint.config.js`'s
6
+ * `no-restricted-syntax`). Delays are executed via an injected {@link
7
+ * SleepFn} so tests can assert the retry schedule without a real wait.
8
+ */
9
+ import type { Random } from "@untangle-code/domain";
10
+
11
+ import type { SleepFn } from "./types.js";
12
+
13
+ export interface BackoffConfig {
14
+ /** Maximum number of retries after the first attempt. */
15
+ maxRetries: number;
16
+ baseDelayMs: number;
17
+ maxDelayMs: number;
18
+ }
19
+
20
+ /**
21
+ * "Full jitter" delay for retry attempt `attempt` (0-based, i.e. the first
22
+ * retry after the initial attempt is `attempt === 0`): uniform random in
23
+ * `[0, min(maxDelayMs, baseDelayMs * 2^attempt)]`.
24
+ */
25
+ export function computeBackoffDelayMs(
26
+ attempt: number,
27
+ config: BackoffConfig,
28
+ random: Random,
29
+ ): number {
30
+ const cap = Math.min(config.maxDelayMs, config.baseDelayMs * 2 ** attempt);
31
+ return Math.round(random.nextFloat() * cap);
32
+ }
33
+
34
+ export interface RetryOptions {
35
+ // `| undefined` spelled out: callers forward an already-optional signal
36
+ // under exactOptionalPropertyTypes.
37
+ signal?: AbortSignal | undefined;
38
+ /** Whether `error` is worth retrying at all; a `false` result stops immediately. */
39
+ isRetryable: (error: unknown) => boolean;
40
+ }
41
+
42
+ /**
43
+ * Runs `fn`, retrying on retryable errors up to `config.maxRetries` times
44
+ * with exponential backoff and jitter (spec.md §9.2). A non-retryable error,
45
+ * or a retryable error that has exhausted its retries, propagates as-is —
46
+ * the caller (the `EmbeddingProvider`) is responsible for turning that into
47
+ * the right `UntangleError` ("repeated permanent errors MUST stop the job in
48
+ * a controlled way", spec.md §9.2).
49
+ *
50
+ * Checks `signal` before every attempt and every sleep so an abort mid-retry
51
+ * surfaces immediately rather than after the next attempt completes.
52
+ */
53
+ export async function withRetry<T>(
54
+ fn: (attempt: number) => Promise<T>,
55
+ config: BackoffConfig,
56
+ deps: { random: Random; sleep: SleepFn },
57
+ options: RetryOptions,
58
+ ): Promise<T> {
59
+ let attempt = 0;
60
+ for (;;) {
61
+ options.signal?.throwIfAborted();
62
+ try {
63
+ return await fn(attempt);
64
+ } catch (error) {
65
+ if (attempt >= config.maxRetries || !options.isRetryable(error)) {
66
+ throw error;
67
+ }
68
+ const delayMs = computeBackoffDelayMs(attempt, config, deps.random);
69
+ await deps.sleep(delayMs, options.signal);
70
+ attempt += 1;
71
+ }
72
+ }
73
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Default in-memory {@link EmbeddingCachePort} (spec.md §9.4).
3
+ *
4
+ * Process-local only — a fresh process starts with an empty cache. This is
5
+ * the right default for a library that must work without any storage
6
+ * adapter configured; `storage-local` (or another persistent adapter) can
7
+ * inject a durable implementation of the same port instead.
8
+ */
9
+ import type { EmbeddingCacheEntry, EmbeddingCacheKey, EmbeddingCachePort } from "./types.js";
10
+
11
+ function cacheKeyString(key: EmbeddingCacheKey): string {
12
+ return `${key.embeddingProfileId}:${key.preparedTextHash}`;
13
+ }
14
+
15
+ export class InMemoryEmbeddingCache implements EmbeddingCachePort {
16
+ private readonly entries = new Map<string, EmbeddingCacheEntry>();
17
+
18
+ public async get(key: EmbeddingCacheKey): Promise<EmbeddingCacheEntry | null> {
19
+ return this.entries.get(cacheKeyString(key)) ?? null;
20
+ }
21
+
22
+ public async set(key: EmbeddingCacheKey, entry: EmbeddingCacheEntry): Promise<void> {
23
+ this.entries.set(cacheKeyString(key), entry);
24
+ }
25
+
26
+ /** Test/debug helper: number of cached entries. */
27
+ public get size(): number {
28
+ return this.entries.size;
29
+ }
30
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Bounded-concurrency map (spec.md §9.2 "Requests MÜSSEN nach
3
+ * Verbindungslimit begrenzt werden").
4
+ *
5
+ * A small counting semaphore rather than a dependency on an external queue
6
+ * library — the whole need here is "run at most N of these concurrently,
7
+ * preserve output order", which is a handful of lines.
8
+ */
9
+
10
+ /** Runs `fn` over `items` with at most `limit` concurrent calls, preserving output order. */
11
+ export async function mapWithConcurrency<T, R>(
12
+ items: readonly T[],
13
+ limit: number,
14
+ fn: (item: T, index: number) => Promise<R>,
15
+ ): Promise<R[]> {
16
+ if (items.length === 0) {
17
+ return [];
18
+ }
19
+ const boundedLimit = Math.max(1, Math.min(limit, items.length));
20
+ const results = new Array<R>(items.length);
21
+ let nextIndex = 0;
22
+
23
+ async function worker(): Promise<void> {
24
+ for (;;) {
25
+ const index = nextIndex;
26
+ nextIndex += 1;
27
+ if (index >= items.length) {
28
+ return;
29
+ }
30
+ const item = items[index] as T;
31
+ results[index] = await fn(item, index);
32
+ }
33
+ }
34
+
35
+ await Promise.all(Array.from({ length: boundedLimit }, () => worker()));
36
+ return results;
37
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Endpoint redaction helper (spec.md §3.2 "Health-Ausgaben MÜSSEN die
3
+ * tatsächlich verwendeten Endpunkte anzeigen, wobei Secrets redigiert
4
+ * werden").
5
+ *
6
+ * `@untangle-code/observability`'s `redact()` redacts object fields by key
7
+ * name; it has no notion of a URL's own embedded-credentials syntax
8
+ * (`https://user:pass@host/...`). This is the narrow, URL-specific
9
+ * complement: it never touches the bearer token sent via the
10
+ * `Authorization` header (that never becomes part of the URL to begin
11
+ * with) — it only strips userinfo that a misconfigured `url` might carry.
12
+ */
13
+ export function redactEndpointUrl(rawUrl: string): string {
14
+ try {
15
+ const url = new URL(rawUrl);
16
+ if (url.username || url.password) {
17
+ url.username = "";
18
+ url.password = "";
19
+ return url.toString();
20
+ }
21
+ return rawUrl;
22
+ } catch {
23
+ // Not a parseable absolute URL — nothing structured to redact.
24
+ return rawUrl;
25
+ }
26
+ }
27
+
28
+ /** Joins a base endpoint URL with a request path, tolerating a trailing slash on the base. */
29
+ export function joinUrl(baseUrl: string, path: string): string {
30
+ return new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
31
+ }
package/src/http.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Minimal Ollama HTTP client on Node 22's built-in `fetch` (no HTTP client
3
+ * dependency, per the WP-10 task). Every request carries a timeout and
4
+ * respects an external `AbortSignal`; a bounded semaphore caps concurrently
5
+ * in-flight requests (spec.md §9.2 "Verbindungslimit").
6
+ */
7
+ import { joinUrl } from "./endpoint.js";
8
+
9
+ /** Thrown by {@link OllamaHttpClient.requestJson} for any failed request. */
10
+ export class OllamaHttpError extends Error {
11
+ public readonly status: number | undefined;
12
+ public readonly retryable: boolean;
13
+
14
+ public constructor(
15
+ message: string,
16
+ options: { status?: number; retryable: boolean; cause?: unknown },
17
+ ) {
18
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
19
+ this.name = "OllamaHttpError";
20
+ this.status = options.status;
21
+ this.retryable = options.retryable;
22
+ }
23
+ }
24
+
25
+ /** Thrown when a request is aborted by the CALLER's own signal (never retried). */
26
+ export class OllamaRequestCancelledError extends Error {
27
+ public constructor(cause?: unknown) {
28
+ super("Ollama request cancelled", cause === undefined ? undefined : { cause });
29
+ this.name = "OllamaRequestCancelledError";
30
+ }
31
+ }
32
+
33
+ /**
34
+ * True for anything that represents the CALLER's own cancellation rather
35
+ * than a transport failure: an {@link OllamaRequestCancelledError}, a raw
36
+ * `AbortError` (e.g. from `backoff.ts`'s own `signal.throwIfAborted()`
37
+ * pre-check, which runs before this client gets a chance to wrap it), or
38
+ * simply `signal.aborted` already being true by the time the error is
39
+ * observed. Used everywhere this adapter must tell "the caller cancelled"
40
+ * apart from "the endpoint is unreachable" (rule 9: cancellation surfaces as
41
+ * `JOB_CANCELLED`, never as a swallowed/misclassified failure).
42
+ */
43
+ export function isRequestCancellation(error: unknown, signal: AbortSignal | undefined): boolean {
44
+ return (
45
+ error instanceof OllamaRequestCancelledError ||
46
+ (error instanceof DOMException && error.name === "AbortError") ||
47
+ signal?.aborted === true
48
+ );
49
+ }
50
+
51
+ function isRetryableStatus(status: number): boolean {
52
+ return status === 429 || status >= 500;
53
+ }
54
+
55
+ /** Counting semaphore bounding concurrent HTTP requests to one Ollama endpoint. */
56
+ class Semaphore {
57
+ private available: number;
58
+ private readonly waiters: (() => void)[] = [];
59
+
60
+ public constructor(limit: number) {
61
+ this.available = Math.max(1, limit);
62
+ }
63
+
64
+ public async acquire(): Promise<() => void> {
65
+ if (this.available > 0) {
66
+ this.available -= 1;
67
+ return () => this.release();
68
+ }
69
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
70
+ this.available -= 1;
71
+ return () => this.release();
72
+ }
73
+
74
+ private release(): void {
75
+ this.available += 1;
76
+ const next = this.waiters.shift();
77
+ if (next) {
78
+ next();
79
+ }
80
+ }
81
+ }
82
+
83
+ export interface OllamaHttpClientOptions {
84
+ requestTimeoutMs: number;
85
+ maxConnections: number;
86
+ // `| undefined` spelled out because callers (provider.ts) forward an
87
+ // already-optional config/deps field here under exactOptionalPropertyTypes.
88
+ apiKeyEnv?: string | undefined;
89
+ /** Injectable for tests; defaults to the global `fetch`. */
90
+ fetchImpl?: typeof fetch | undefined;
91
+ }
92
+
93
+ export class OllamaHttpClient {
94
+ private readonly semaphore: Semaphore;
95
+ private readonly fetchImpl: typeof fetch;
96
+ private readonly requestTimeoutMs: number;
97
+ private readonly apiKeyEnv: string | undefined;
98
+
99
+ public constructor(options: OllamaHttpClientOptions) {
100
+ this.semaphore = new Semaphore(options.maxConnections);
101
+ this.fetchImpl = options.fetchImpl ?? fetch;
102
+ this.requestTimeoutMs = options.requestTimeoutMs;
103
+ this.apiKeyEnv = options.apiKeyEnv;
104
+ }
105
+
106
+ /**
107
+ * Sends one JSON request and parses a JSON response. Never retries by
108
+ * itself — callers wrap this in `backoff.ts`'s `withRetry`.
109
+ *
110
+ * Throws {@link OllamaRequestCancelledError} when `signal` (the caller's
111
+ * own signal, not the internal timeout) is the reason for the abort, and
112
+ * {@link OllamaHttpError} for every other failure (network error, timeout,
113
+ * non-2xx status), with `retryable` set per spec.md §9.2's transient/
114
+ * permanent split.
115
+ */
116
+ public async requestJson<T>(
117
+ baseUrl: string,
118
+ path: string,
119
+ init: { method: "GET" | "POST"; body?: unknown },
120
+ signal?: AbortSignal,
121
+ ): Promise<T> {
122
+ if (signal?.aborted) {
123
+ throw new OllamaRequestCancelledError(signal.reason);
124
+ }
125
+ const release = await this.semaphore.acquire();
126
+ try {
127
+ const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
128
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
129
+ const headers: Record<string, string> = { "content-type": "application/json" };
130
+ const token = this.apiKeyEnv ? process.env[this.apiKeyEnv] : undefined;
131
+ if (token) {
132
+ headers["authorization"] = `Bearer ${token}`;
133
+ }
134
+ let response: Response;
135
+ try {
136
+ response = await this.fetchImpl(joinUrl(baseUrl, path), {
137
+ method: init.method,
138
+ headers,
139
+ signal: combinedSignal,
140
+ // Conditionally spread rather than `body: undefined` — RequestInit's
141
+ // `body` has no `| undefined` arm, so exactOptionalPropertyTypes
142
+ // forbids assigning it explicitly (a GET request has no body).
143
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
144
+ });
145
+ } catch (error) {
146
+ if (signal?.aborted) {
147
+ throw new OllamaRequestCancelledError(error);
148
+ }
149
+ // Network failure (connection refused, DNS, our own timeout) — transient.
150
+ throw new OllamaHttpError(`Ollama request to ${path} failed: ${String(error)}`, {
151
+ retryable: true,
152
+ cause: error,
153
+ });
154
+ }
155
+ if (!response.ok) {
156
+ const bodyText = await response.text().catch(() => "");
157
+ throw new OllamaHttpError(
158
+ `Ollama request to ${path} returned HTTP ${response.status}: ${bodyText.slice(0, 500)}`,
159
+ { status: response.status, retryable: isRetryableStatus(response.status) },
160
+ );
161
+ }
162
+ const text = await response.text();
163
+ return text.length === 0 ? (undefined as T) : (JSON.parse(text) as T);
164
+ } finally {
165
+ release();
166
+ }
167
+ }
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @untangle-code/embeddings-ollama — `EmbeddingProvider` adapter for Ollama
3
+ * (spec.md §9, architecture.md §6.8). This module is the only public export
4
+ * of the package (rule 11); everything else (`http.ts`, `ollama-api.ts`,
5
+ * `readiness.ts`, `backoff.ts`, `concurrency.ts`, `truncation.ts`,
6
+ * `endpoint.ts`, `validation.ts`) is an internal implementation detail,
7
+ * imported directly only by this package's own co-located tests.
8
+ */
9
+
10
+ // The adapter itself.
11
+ export * from "./provider.js";
12
+
13
+ // Public configuration and port types.
14
+ export * from "./types.js";
15
+
16
+ // Default in-memory embedding cache (spec.md §9.4) — the port itself lives in types.ts.
17
+ export * from "./cache.js";
18
+
19
+ // Real (non-fake) sleep implementation, exported so callers assembling
20
+ // `OllamaEmbeddingProviderDeps` outside tests don't need to reimplement it.
21
+ export * from "./sleep.js";