@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.
- package/README.md +119 -12
- package/dist/canonical.d.ts.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +91 -19
- package/dist/cloudflare-embedding.d.ts +104 -0
- package/dist/cloudflare-embedding.d.ts.map +1 -0
- package/dist/graph.d.ts.map +1 -1
- package/dist/index.js +90 -18
- package/dist/libsql-semantic.d.ts +130 -0
- package/dist/libsql-semantic.d.ts.map +1 -0
- package/dist/libsql.js +105 -18
- package/dist/memory-page.d.ts +2 -0
- package/dist/memory-page.d.ts.map +1 -0
- package/dist/memory-page.js +725 -0
- package/dist/memory-pages.d.ts +76 -0
- package/dist/memory-pages.d.ts.map +1 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +478 -18
- package/dist/projection-public.js +105 -18
- package/dist/projection-suss.js +105 -18
- package/dist/sdk.js +90 -18
- package/dist/semantic-cloud.d.ts +3 -0
- package/dist/semantic-cloud.d.ts.map +1 -0
- package/dist/semantic-cloud.js +1870 -0
- package/dist/semantic.d.ts.map +1 -1
- package/dist/semantic.js +104 -21
- package/dist/sqlite/index.js +90 -18
- package/dist/store.js +105 -18
- package/dist/sync.js +90 -18
- package/package.json +10 -2
- package/skills/oh/SKILL.md +28 -2
- package/spec/README.md +11 -3
- package/spec/manifest.json +9 -1
- package/spec/v1/cloudflare-embedding-profile.json +13 -0
- package/spec/v1/cloudflare-embedding-renderer.json +8 -0
- package/spec/v1/memory-page.md +153 -0
- package/spec/v1/memory-page.schema.json +154 -0
- package/spec/v1/memory.md +18 -0
- package/spec/v1/migration.md +13 -0
- package/spec/v1/semantic-cloud.md +96 -0
- package/src/canonical.ts +28 -13
- package/src/cli.ts +1 -1
- package/src/cloudflare-embedding.test.ts +306 -0
- package/src/cloudflare-embedding.ts +385 -0
- package/src/contracts.test.ts +20 -0
- package/src/graph.ts +63 -6
- package/src/libsql-semantic.test.ts +585 -0
- package/src/libsql-semantic.ts +1168 -0
- package/src/memory-page.ts +1 -0
- package/src/memory-pages.test.ts +277 -0
- package/src/memory-pages.ts +440 -0
- package/src/memory.ts +2 -0
- package/src/semantic-cloud.ts +2 -0
- 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,
|
package/spec/v1/migration.md
CHANGED
|
@@ -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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
101
|
-
const
|
|
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(
|
|
118
|
+
return `${JSON.stringify(key)}:${encodeCanonical(entryValue, `${path}.${key}`, ancestors)}`;
|
|
104
119
|
});
|
|
105
|
-
return `{${
|
|
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.
|
|
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
|
+
});
|