@hraness/oh 0.2.7 → 0.3.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 (55) hide show
  1. package/README.md +119 -12
  2. package/dist/canonical.d.ts.map +1 -1
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.js +91 -19
  5. package/dist/cloudflare-embedding.d.ts +104 -0
  6. package/dist/cloudflare-embedding.d.ts.map +1 -0
  7. package/dist/graph.d.ts.map +1 -1
  8. package/dist/index.js +90 -18
  9. package/dist/libsql-semantic.d.ts +130 -0
  10. package/dist/libsql-semantic.d.ts.map +1 -0
  11. package/dist/libsql.js +105 -18
  12. package/dist/memory-page.d.ts +2 -0
  13. package/dist/memory-page.d.ts.map +1 -0
  14. package/dist/memory-page.js +725 -0
  15. package/dist/memory-pages.d.ts +76 -0
  16. package/dist/memory-pages.d.ts.map +1 -0
  17. package/dist/memory.d.ts +1 -0
  18. package/dist/memory.d.ts.map +1 -1
  19. package/dist/memory.js +478 -18
  20. package/dist/projection-public.js +105 -18
  21. package/dist/projection-suss.js +105 -18
  22. package/dist/sdk.js +90 -18
  23. package/dist/semantic-cloud.d.ts +3 -0
  24. package/dist/semantic-cloud.d.ts.map +1 -0
  25. package/dist/semantic-cloud.js +1870 -0
  26. package/dist/semantic.d.ts.map +1 -1
  27. package/dist/semantic.js +104 -21
  28. package/dist/sqlite/index.js +90 -18
  29. package/dist/store.js +105 -18
  30. package/dist/sync.js +90 -18
  31. package/package.json +10 -2
  32. package/skills/oh/SKILL.md +28 -2
  33. package/spec/README.md +11 -3
  34. package/spec/manifest.json +9 -1
  35. package/spec/v1/cloudflare-embedding-profile.json +13 -0
  36. package/spec/v1/cloudflare-embedding-renderer.json +8 -0
  37. package/spec/v1/memory-page.md +153 -0
  38. package/spec/v1/memory-page.schema.json +154 -0
  39. package/spec/v1/memory.md +18 -0
  40. package/spec/v1/migration.md +13 -0
  41. package/spec/v1/semantic-cloud.md +96 -0
  42. package/src/canonical.ts +28 -13
  43. package/src/cli.ts +1 -1
  44. package/src/cloudflare-embedding.test.ts +306 -0
  45. package/src/cloudflare-embedding.ts +385 -0
  46. package/src/contracts.test.ts +20 -0
  47. package/src/graph.ts +63 -6
  48. package/src/libsql-semantic.test.ts +585 -0
  49. package/src/libsql-semantic.ts +1168 -0
  50. package/src/memory-page.ts +1 -0
  51. package/src/memory-pages.test.ts +277 -0
  52. package/src/memory-pages.ts +440 -0
  53. package/src/memory.ts +2 -0
  54. package/src/semantic-cloud.ts +2 -0
  55. package/src/semantic.ts +14 -3
package/spec/v1/memory.md CHANGED
@@ -169,6 +169,24 @@ canonical store, import a derived tuple, grant rights, record a review, or turn
169
169
  a proposed assertion into reviewed knowledge. Destination-owned application
170
170
  code must perform those steps under its own policy and compare-and-swap head.
171
171
 
172
+ ## Memory pages and retrieval
173
+
174
+ A memory page is an application profile for an ordinary `edition` record. Its
175
+ bounded title, summary, Markdown body, exact source observations, and
176
+ host-attested provenance are authoritative record content. The optional
177
+ canonical `.oh.md` rendering is a self-contained one-record interchange file;
178
+ it does not introduce another database or operation format. This gives agents
179
+ a readable, diffable artifact without making Markdown syntax the hidden source
180
+ of graph authority.
181
+
182
+ Memory pages deliberately contain no model, vector, score, index generation,
183
+ or provider field. The local embedding backend and the hosted semantic cache
184
+ derive disposable indexes from the same current record digests under different
185
+ profiles. A host may expose semantic recall as an additional convenience, but
186
+ it MUST preserve exact `remember`, Datalog `query`, `explain`, and `nominate`
187
+ semantics when retrieval is unavailable. Search hits are rejoined to current
188
+ authority digests and remain retrieval evidence, not accepted facts.
189
+
172
190
  ## Lifecycle and custody boundary
173
191
 
174
192
  Oh deliberately does not choose a tenant, session, retention deadline,
@@ -67,6 +67,19 @@ Semantic state MAY be rebuilt with the exact profile in
67
67
  only after it rejoins a record with the same record digest. Copying a semantic
68
68
  directory does not establish migration parity.
69
69
 
70
+ The optional hosted cache is rebuilt from authoritative records with both the
71
+ exact [`cloudflare-embedding-profile.json`](cloudflare-embedding-profile.json)
72
+ and [`cloudflare-embedding-renderer.json`](cloudflare-embedding-renderer.json)
73
+ identities. Do not copy or mix vectors across local, hosted, profile, or
74
+ renderer identities. A permanently purged hosted authority ID cannot be reused;
75
+ allocate a new host-bound authority epoch and stage a complete generation from
76
+ the current source records.
77
+
78
+ A canonical `.oh.md` file transports one complete memory-page record and
79
+ recomputes its record digest. It does not carry an operation chain, store head,
80
+ realm binding, review decision, or proof of full authority migration. Use an Oh
81
+ operation bundle or destination-owned adoption workflow for those purposes.
82
+
70
83
  ## Rollback
71
84
 
72
85
  Keep the verified source database read-only until the target head has matched
@@ -0,0 +1,96 @@
1
+ # Hosted semantic cache V1
2
+
3
+ `@hraness/oh/semantic-cloud` is a rebuildable retrieval adapter. It combines
4
+ one fixed Cloudflare Workers AI EmbeddingGemma profile with a separate direct
5
+ libSQL cache. It is not an Oh graph authority, a third memory lane, or a vector
6
+ field in an Oh record.
7
+
8
+ ## Profile and portability
9
+
10
+ The hosted profile is `oh.cloudflare.embeddinggemma.v1`. It uses model
11
+ `@cf/google/embeddinggemma-300m`, 768 dimensions, L2 normalization, and cosine
12
+ similarity. Queries use `task: search result | query: {query}`. Document chunks
13
+ use `title: {title} | text: {content}`.
14
+
15
+ The renderer greedily partitions content on Unicode-scalar boundaries. Every
16
+ complete formatted input is at most 448 UTF-8 bytes. It exposes partial or
17
+ overlarge-prefix diagnostics instead of silently dropping text. The cache
18
+ stager accepts only complete documents, at most 64 chunks per document, 512
19
+ documents, and 4,096 chunks in one authority generation.
20
+
21
+ The profile digest and renderer digest identify the exact derived space.
22
+ Their complete canonical payloads and digests are published in
23
+ [`cloudflare-embedding-profile.json`](cloudflare-embedding-profile.json) and
24
+ [`cloudflare-embedding-renderer.json`](cloudflare-embedding-renderer.json).
25
+ Implementations MUST NOT mix vectors across either digest. A local quantized
26
+ implementation and a hosted implementation may use the same model family but
27
+ are not assumed to emit byte-identical or interchangeable vectors. Portable
28
+ authority comes from the source records: deleting and rebuilding an index is
29
+ the model-migration path.
30
+
31
+ ## Provider boundary
32
+
33
+ `OhCloudflareEmbeddingClientV1` sends one bounded POST to the fixed Workers AI
34
+ account/model route, rejects redirects, applies a caller-cancellable deadline,
35
+ and accepts only the exact successful shape. It validates and normalizes every
36
+ finite nonzero vector. Errors retain only a fixed classification and optional
37
+ HTTP status; credentials and response bodies do not enter an error.
38
+
39
+ The host owns the account, token, spend controls, privacy decision, and network
40
+ policy. Agent input MUST NOT select an account, endpoint, token, model, batch
41
+ limit, or profile. Source text necessarily crosses the provider boundary when
42
+ it is embedded, even though it is not persisted in the semantic cache.
43
+
44
+ ## Derived libSQL cache
45
+
46
+ Schema creation is an explicit deployment operation through
47
+ `bootstrapOhLibSqlSemanticCacheV1`. `openOhLibSqlSemanticCacheV1` verifies the
48
+ complete schema inventory and performs no DDL. Applications SHOULD use a
49
+ short-lived schema credential and separate runtime/purge credentials.
50
+
51
+ The cache stores:
52
+
53
+ - normalized vectors as exact 3,072-byte little-endian float32 blobs;
54
+ - vector digests keyed by profile, renderer, and formatted-input digest;
55
+ - immutable record/chunk membership for staged authority generations;
56
+ - one compare-and-swap published generation per authority; and
57
+ - permanent authority purge tombstones.
58
+
59
+ It stores no title, source content, query, page body, record JSON, account ID,
60
+ or provider token. A formatted-input digest permits vector reuse without
61
+ retaining its plaintext. Cache database access still deserves protection: its
62
+ keys, record digests, generation timing, and vector geometry are metadata.
63
+
64
+ Publishing never mutates a staged generation. Search pins one published head,
65
+ scans at most its bounded 4,096 chunks in fixed pages, computes exact cosine in
66
+ the application, keeps the best chunk per record, and rereads the head. Every
67
+ hit is rejoined to a caller-supplied current authority record digest. A stale
68
+ head, changed authority digest, changed record digest, concurrent publish, or
69
+ purge returns no stale authority.
70
+
71
+ `publishedHead` returns the current compare-and-swap base or `null` for an
72
+ absent or purged authority. Its bounded projection contains only the authority,
73
+ generation, profile, renderer, membership, generation and publication
74
+ identities already held by the cache; it exposes no vector, record body or
75
+ formatted input. The read verifies that the pointer still matches its immutable
76
+ generation and fails with an integrity error on divergence. A host can read the
77
+ base, stage a later authoritative generation, and pass the returned generation
78
+ to `publish` without keeping a second cache pointer elsewhere.
79
+
80
+ `purgeAuthority` writes a permanent tombstone, removes the head, memberships,
81
+ and staged generations, then deletes vectors no remaining authority uses. The
82
+ tombstone prevents the same authority ID from being staged or published again.
83
+ Hosts SHOULD include a session epoch in the authority ID and allocate a new ID
84
+ for a genuinely new lifetime.
85
+
86
+ ## Failure and lifecycle rules
87
+
88
+ Hosted semantic failure MUST NOT weaken exact graph or Datalog operations. A
89
+ consumer may return semantic-unavailable or omit the recall lane while keeping
90
+ authoritative remember, exact query, read, and explanation operations intact.
91
+
92
+ For a purgeable working authority, the lifecycle worker MUST stop new writes,
93
+ purge the derived semantic authority, then purge the authoritative working
94
+ store. It acknowledges completion only after both operations have converged.
95
+ Both purges are idempotent. A failed cache purge remains retryable and MUST NOT
96
+ be reported as complete merely because the authority purge succeeded.
package/src/canonical.ts CHANGED
@@ -69,18 +69,31 @@ function encodeCanonical(value: unknown, path: string, ancestors: Set<object>):
69
69
  ancestors.add(value);
70
70
  try {
71
71
  if (Array.isArray(value)) {
72
- const encoded: string[] = [];
73
- for (let index = 0; index < value.length; index += 1) {
74
- if (!Object.hasOwn(value, index)) {
75
- throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes");
76
- }
77
- encoded.push(encodeCanonical(value[index], `${path}[${index}]`, ancestors));
72
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
73
+ const length = lengthDescriptor?.value;
74
+ if (typeof length !== "number" || !Number.isSafeInteger(length) || length < 0) {
75
+ throw new OhValidationError("non-json-property", path, "array has an invalid length descriptor");
78
76
  }
79
- const extraKeys = Reflect.ownKeys(value).filter((key) => key !== "length"
80
- && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length));
81
- if (extraKeys.length > 0) {
77
+ const ownKeys = Reflect.ownKeys(value);
78
+ if (!ownKeys.includes("length")
79
+ || ownKeys.some((key) => key !== "length" && (typeof key !== "string"
80
+ || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length))) {
82
81
  throw new OhValidationError("non-json-property", path, "array has non-index properties");
83
82
  }
83
+ const elements: unknown[] = [];
84
+ for (let index = 0; index < length; index += 1) {
85
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
86
+ if (descriptor === undefined) {
87
+ throw new OhValidationError("sparse-array", `${path}[${index}]`, "must not contain holes");
88
+ }
89
+ if (!descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) {
90
+ throw new OhValidationError("non-json-property", `${path}[${index}]`,
91
+ "must be an enumerable data property");
92
+ }
93
+ elements.push(descriptor.value);
94
+ }
95
+ const encoded = elements.map((element, index) =>
96
+ encodeCanonical(element, `${path}[${index}]`, ancestors));
84
97
  return `[${encoded.join(",")}]`;
85
98
  }
86
99
  if (!isPlainRecord(value)) {
@@ -90,19 +103,21 @@ function encodeCanonical(value: unknown, path: string, ancestors: Set<object>):
90
103
  if (ownKeys.some((key) => typeof key !== "string")) {
91
104
  throw new OhValidationError("non-json-property", path, "object has a symbol property");
92
105
  }
106
+ const entries: Array<readonly [string, unknown]> = [];
93
107
  const keys = ownKeys as string[];
94
108
  for (const key of keys) {
95
109
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
96
110
  if (descriptor === undefined || !descriptor.enumerable || descriptor.get !== undefined || descriptor.set !== undefined) {
97
111
  throw new OhValidationError("non-json-property", `${path}.${key}`, "must be an enumerable data property");
98
112
  }
113
+ entries.push([key, descriptor.value]);
99
114
  }
100
- keys.sort();
101
- const entries = keys.map((key) => {
115
+ entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
116
+ const encodedEntries = entries.map(([key, entryValue]) => {
102
117
  assertUnicodeScalarString(key, `${path}.<key>`);
103
- return `${JSON.stringify(key)}:${encodeCanonical(value[key], `${path}.${key}`, ancestors)}`;
118
+ return `${JSON.stringify(key)}:${encodeCanonical(entryValue, `${path}.${key}`, ancestors)}`;
104
119
  });
105
- return `{${entries.join(",")}}`;
120
+ return `{${encodedEntries.join(",")}}`;
106
121
  } finally {
107
122
  ancestors.delete(value);
108
123
  }
package/src/cli.ts CHANGED
@@ -9,7 +9,7 @@ import { Oh } from "./sdk";
9
9
  import { OH_SQLITE_SCHEMA_VERSION } from "./sqlite/migrations";
10
10
  import { createOhSyncBundleV1, parseOhSyncBundleV1 } from "./sync";
11
11
 
12
- export const OH_PACKAGE_VERSION = "0.2.7" as const;
12
+ export const OH_PACKAGE_VERSION = "0.3.1" as const;
13
13
 
14
14
  type ParsedArguments = { options: Map<string, string[]>; positionals: string[] };
15
15
  type ValidatedInvocation = Readonly<{
@@ -0,0 +1,306 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { canonicalSha256, sha256Hex, utf8ByteLength } from "./canonical";
4
+ import {
5
+ OH_CLOUDFLARE_EMBEDDING_LIMITS_V1,
6
+ OH_CLOUDFLARE_EMBEDDING_PROFILE_V1,
7
+ OH_SEMANTIC_RENDERER_V1,
8
+ OhCloudflareEmbeddingClientV1,
9
+ OhCloudflareEmbeddingError,
10
+ renderOhCloudflareEmbeddingDocumentV1,
11
+ renderOhCloudflareEmbeddingQueryV1,
12
+ type OhEmbeddingFetchV1,
13
+ type OhRenderedEmbeddingInputV1,
14
+ } from "./cloudflare-embedding";
15
+
16
+ const accountId = "0123456789abcdef0123456789abcdef";
17
+ const apiToken = "cloudflare-test-token-never-a-real-secret";
18
+
19
+ function unitVector(index = 0): number[] {
20
+ return Array.from(
21
+ { length: OH_CLOUDFLARE_EMBEDDING_PROFILE_V1.dimensions },
22
+ (_, ordinal) => ordinal === index ? 1 : 0,
23
+ );
24
+ }
25
+
26
+ function success(vectors: readonly (readonly number[])[]): Response {
27
+ return Response.json({
28
+ result: {
29
+ data: vectors,
30
+ shape: [vectors.length, OH_CLOUDFLARE_EMBEDDING_PROFILE_V1.dimensions],
31
+ },
32
+ success: true,
33
+ });
34
+ }
35
+
36
+ describe("Cloudflare EmbeddingGemma profile", () => {
37
+ test("pins a provider-specific, rebuildable embedding and renderer identity", () => {
38
+ expect(OH_CLOUDFLARE_EMBEDDING_PROFILE_V1).toMatchObject({
39
+ dimensions: 768,
40
+ distance: "cosine",
41
+ inputUtf8Bytes: 448,
42
+ model: "@cf/google/embeddinggemma-300m",
43
+ normalization: "l2",
44
+ profileId: "oh.cloudflare.embeddinggemma.v1",
45
+ provider: "cloudflare.workers-ai",
46
+ v: 1,
47
+ });
48
+ const { profileSha256, ...profile } = OH_CLOUDFLARE_EMBEDDING_PROFILE_V1;
49
+ const { rendererSha256, ...renderer } = OH_SEMANTIC_RENDERER_V1;
50
+ expect(profileSha256).toBe(canonicalSha256(profile));
51
+ expect(rendererSha256).toBe(canonicalSha256(renderer));
52
+ expect(profileSha256).not.toBe(rendererSha256);
53
+ });
54
+
55
+ test("renders bounded query and complete Unicode-scalar document inputs", () => {
56
+ const query = renderOhCloudflareEmbeddingQueryV1("where did the session stop?");
57
+ expect(query).toEqual({
58
+ input: "task: search result | query: where did the session stop?",
59
+ inputSha256: sha256Hex(query.input),
60
+ kind: "query",
61
+ utf8Bytes: utf8ByteLength(query.input),
62
+ v: 1,
63
+ });
64
+
65
+ const source = `${"a".repeat(420)}🧠${"b".repeat(420)}`;
66
+ const rendered = renderOhCloudflareEmbeddingDocumentV1({
67
+ content: source,
68
+ title: "session",
69
+ });
70
+ expect(rendered.status).toBe("complete");
71
+ expect(rendered.diagnostic).toBeNull();
72
+ expect(rendered.chunks.length).toBeGreaterThan(1);
73
+ expect(rendered.chunks.map(({ content }) => content).join("")).toBe(source);
74
+ for (const [ordinal, chunk] of rendered.chunks.entries()) {
75
+ expect(chunk.ordinal).toBe(ordinal);
76
+ expect(chunk.input.kind).toBe("document");
77
+ expect(chunk.input.utf8Bytes).toBeLessThanOrEqual(448);
78
+ expect(chunk.input.inputSha256).toBe(sha256Hex(chunk.input.input));
79
+ expect(/[\ud800-\udbff]$/u.test(chunk.content)).toBeFalse();
80
+ expect(/^[\udc00-\udfff]/u.test(chunk.content)).toBeFalse();
81
+ }
82
+ });
83
+
84
+ test("makes truncation and an overlarge prefix explicit", () => {
85
+ const partial = renderOhCloudflareEmbeddingDocumentV1({
86
+ content: "x".repeat(2_000),
87
+ maximumChunks: 1,
88
+ title: "bounded",
89
+ });
90
+ expect(partial.status).toBe("partial");
91
+ expect(partial.diagnostic).toMatchObject({
92
+ code: "partial",
93
+ maximumChunks: 1,
94
+ });
95
+ expect(partial.diagnostic?.omittedUtf8Bytes).toBeGreaterThan(0);
96
+
97
+ const oversize = renderOhCloudflareEmbeddingDocumentV1({
98
+ content: "content",
99
+ title: "t".repeat(448),
100
+ });
101
+ expect(oversize).toMatchObject({
102
+ chunks: [],
103
+ diagnostic: { code: "oversize-prefix" },
104
+ status: "oversize",
105
+ });
106
+
107
+ const fixedPrefixBytes = utf8ByteLength("title: | text: ");
108
+ for (const residualBytes of [1, 2, 3]) {
109
+ const scalarOversize = renderOhCloudflareEmbeddingDocumentV1({
110
+ content: "🧠",
111
+ title: "t".repeat(448 - fixedPrefixBytes - residualBytes),
112
+ });
113
+ expect(scalarOversize).toMatchObject({
114
+ chunks: [],
115
+ diagnostic: { code: "oversize-prefix", omittedUtf8Bytes: 4 },
116
+ status: "oversize",
117
+ });
118
+ }
119
+
120
+ const scalarPartial = renderOhCloudflareEmbeddingDocumentV1({
121
+ content: "a🧠",
122
+ title: "t".repeat(448 - fixedPrefixBytes - 1),
123
+ });
124
+ expect(scalarPartial).toMatchObject({
125
+ chunks: [{ content: "a", ordinal: 0 }],
126
+ diagnostic: { code: "partial", omittedUtf8Bytes: 4 },
127
+ status: "partial",
128
+ });
129
+ });
130
+
131
+ test("rejects noncanonical text and formatted inputs over the byte boundary", () => {
132
+ expect(() => renderOhCloudflareEmbeddingQueryV1("e\u0301"))
133
+ .toThrow(OhCloudflareEmbeddingError);
134
+ expect(() => renderOhCloudflareEmbeddingQueryV1("q".repeat(448)))
135
+ .toThrow("formatted embedding input exceeds");
136
+ expect(() => renderOhCloudflareEmbeddingDocumentV1({
137
+ content: "text",
138
+ maximumChunks: OH_CLOUDFLARE_EMBEDDING_LIMITS_V1.renderedChunks + 1,
139
+ title: "title",
140
+ })).toThrow(OhCloudflareEmbeddingError);
141
+ });
142
+ });
143
+
144
+ describe("Cloudflare Workers AI embedding client", () => {
145
+ test("sends one fixed no-redirect request and returns normalized vectors", async () => {
146
+ const requests: Array<Readonly<{ body: unknown; headers: Headers; url: string }>> = [];
147
+ const fetch: OhEmbeddingFetchV1 = async (input, init) => {
148
+ requests.push({
149
+ body: JSON.parse(String(init?.body)) as unknown,
150
+ headers: new Headers(init?.headers),
151
+ url: String(input),
152
+ });
153
+ expect(init?.method).toBe("POST");
154
+ expect(init?.redirect).toBe("error");
155
+ expect(init?.signal).toBeInstanceOf(AbortSignal);
156
+ return success([unitVector(3), unitVector(4).map((value) => value * 7)]);
157
+ };
158
+ const client = new OhCloudflareEmbeddingClientV1({ accountId, apiToken, fetch });
159
+ const inputs = [
160
+ renderOhCloudflareEmbeddingQueryV1("alpha"),
161
+ renderOhCloudflareEmbeddingQueryV1("beta"),
162
+ ] as const;
163
+ const vectors = await client.embed(inputs);
164
+ expect(vectors).toHaveLength(2);
165
+ expect(vectors[0]?.[3]).toBe(1);
166
+ expect(vectors[1]?.[4]).toBe(1);
167
+ expect(requests).toEqual([{
168
+ body: { text: inputs.map(({ input }) => input) },
169
+ headers: new Headers({
170
+ authorization: `Bearer ${apiToken}`,
171
+ "content-type": "application/json",
172
+ }),
173
+ url: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/google/embeddinggemma-300m`,
174
+ }]);
175
+ });
176
+
177
+ test("rejects forged rendered inputs before network access", async () => {
178
+ let calls = 0;
179
+ const client = new OhCloudflareEmbeddingClientV1({
180
+ accountId,
181
+ apiToken,
182
+ fetch: async () => { calls += 1; return success([unitVector()]); },
183
+ });
184
+ const input = renderOhCloudflareEmbeddingQueryV1("safe");
185
+ await expect(client.embed([{ ...input, inputSha256: "0".repeat(64) as typeof input.inputSha256 }]))
186
+ .rejects.toMatchObject({ code: "invalid-input" });
187
+ const forge = (text: string): OhRenderedEmbeddingInputV1 => Object.freeze({
188
+ input: text,
189
+ inputSha256: sha256Hex(text),
190
+ kind: "query",
191
+ utf8Bytes: utf8ByteLength(text),
192
+ v: 1,
193
+ });
194
+ for (const forged of [
195
+ Object.freeze({ ...input }),
196
+ forge("task: search result | query: e\u0301"),
197
+ forge("task: search result | query: \ud800"),
198
+ forge("not a rendered query"),
199
+ ]) {
200
+ await expect(client.embed([forged])).rejects.toMatchObject({ code: "invalid-input" });
201
+ }
202
+ expect(calls).toBe(0);
203
+ });
204
+
205
+ test("normalizes extreme finite provider vectors without collapsing them to zero", async () => {
206
+ const extreme = unitVector();
207
+ extreme[0] = Number.MAX_VALUE;
208
+ const client = new OhCloudflareEmbeddingClientV1({
209
+ accountId,
210
+ apiToken,
211
+ fetch: async () => success([extreme]),
212
+ });
213
+ const [vector] = await client.embed([renderOhCloudflareEmbeddingQueryV1("query")]);
214
+ expect(vector?.[0]).toBe(1);
215
+ expect(vector?.reduce((sum, component) => sum + component * component, 0)).toBeCloseTo(1, 12);
216
+ });
217
+
218
+ test("bounds and validates every successful provider response", async () => {
219
+ const input = renderOhCloudflareEmbeddingQueryV1("query");
220
+ for (const response of [
221
+ Response.json({ success: false }),
222
+ Response.json({ result: { data: [unitVector()], shape: [1, 1] }, success: true }),
223
+ Response.json({ result: { data: [[...unitVector().slice(1)]], shape: [1, 768] }, success: true }),
224
+ Response.json({ result: { data: [[Number.NaN, ...unitVector().slice(1)]], shape: [1, 768] }, success: true }),
225
+ new Response("x".repeat(1_024), { status: 200 }),
226
+ ]) {
227
+ const client = new OhCloudflareEmbeddingClientV1({
228
+ accountId,
229
+ apiToken,
230
+ fetch: async () => response,
231
+ maximumResponseBytes: 512,
232
+ });
233
+ await expect(client.embed([input])).rejects.toMatchObject({ code: "invalid-response" });
234
+ }
235
+ });
236
+
237
+ test("surfaces only a bounded status classification on provider failures", async () => {
238
+ const client = new OhCloudflareEmbeddingClientV1({
239
+ accountId,
240
+ apiToken,
241
+ fetch: async () => new Response(`never echo ${apiToken}`, { status: 429 }),
242
+ });
243
+ const error = await client.embed([renderOhCloudflareEmbeddingQueryV1("query")])
244
+ .then(() => null, (caught: unknown) => caught);
245
+ expect(error).toMatchObject({ code: "provider-unavailable", status: 429 });
246
+ expect(String(error)).not.toContain(apiToken);
247
+ expect(String(error)).not.toContain(accountId);
248
+ });
249
+
250
+ test("classifies transport cancellation without retaining its cause", async () => {
251
+ const fetch: OhEmbeddingFetchV1 = async (_input, init) => await new Promise<Response>((_resolve, reject) => {
252
+ init?.signal?.addEventListener("abort", () => reject(new Error(`private ${apiToken}`)), { once: true });
253
+ });
254
+ const client = new OhCloudflareEmbeddingClientV1({
255
+ accountId,
256
+ apiToken,
257
+ deadlineMs: 1,
258
+ fetch,
259
+ });
260
+ const error = await client.embed([renderOhCloudflareEmbeddingQueryV1("query")])
261
+ .then(() => null, (caught: unknown) => caught);
262
+ expect(error).toMatchObject({ code: "aborted", status: null });
263
+ expect(String(error)).not.toContain(apiToken);
264
+ });
265
+
266
+ test("classifies cancellation while reading a response body as aborted", async () => {
267
+ const external = new AbortController();
268
+ const fetch: OhEmbeddingFetchV1 = async (_input, init) => new Response(
269
+ new ReadableStream<Uint8Array>({
270
+ start(controller) {
271
+ init?.signal?.addEventListener(
272
+ "abort",
273
+ () => controller.error(new Error(`private ${apiToken}`)),
274
+ { once: true },
275
+ );
276
+ setTimeout(() => external.abort(), 0);
277
+ },
278
+ }),
279
+ { status: 200 },
280
+ );
281
+ const client = new OhCloudflareEmbeddingClientV1({
282
+ accountId,
283
+ apiToken,
284
+ deadlineMs: 1_000,
285
+ fetch,
286
+ });
287
+ const error = await client.embed(
288
+ [renderOhCloudflareEmbeddingQueryV1("query")],
289
+ { signal: external.signal },
290
+ ).then(() => null, (caught: unknown) => caught);
291
+ expect(error).toMatchObject({ code: "aborted", status: null });
292
+ expect(String(error)).not.toContain(apiToken);
293
+ });
294
+
295
+ test("validates credential and request-budget configuration without network access", () => {
296
+ expect(() => new OhCloudflareEmbeddingClientV1({ accountId: "wrong", apiToken }))
297
+ .toThrow("credentials are malformed");
298
+ expect(() => new OhCloudflareEmbeddingClientV1({ accountId, apiToken: "short" }))
299
+ .toThrow("credentials are malformed");
300
+ expect(() => new OhCloudflareEmbeddingClientV1({
301
+ accountId,
302
+ apiToken,
303
+ maximumBatchInputs: 33,
304
+ })).toThrow(RangeError);
305
+ });
306
+ });