@ultimat3/ai 19.2.0 → 19.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/CLAUDE.md +8 -0
- package/package.json +10 -10
- package/src/embeddings.ts +16 -1
- package/src/errors.ts +17 -6
- package/src/pg-vector-sql.ts +5 -1
- package/src/rag.ts +3 -0
- package/src/scorers.ts +6 -1
- package/src/wire.ts +8 -2
package/CLAUDE.md
CHANGED
|
@@ -249,6 +249,14 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
249
249
|
closes, so nothing partial reaches a caller.
|
|
250
250
|
- Thinking chunks are never appended to `text`. A consumer concatenating every chunk must not
|
|
251
251
|
end up shipping the reasoning to the user.
|
|
252
|
+
- **`embedBatched` enforces the `Embedder` arity, per batch** (`As of 2026-09`). "One vector per
|
|
253
|
+
input text, in the order the texts arrived" is the interface's own invariant and nothing
|
|
254
|
+
downstream can restore it: `indexDocument` writes `vectors[index]` per chunk, so an app embedder
|
|
255
|
+
that answered short stored `undefined` and surfaced a layer later as a `TypeError` inside the
|
|
256
|
+
vector store, naming nothing an author wrote. `X_AI_EMBEDDER_INVALID` — the shipped code whose
|
|
257
|
+
registered title already read "an Embedder returned fewer vectors than texts it was given" — now
|
|
258
|
+
carries the two counts in its cause and `meta`. `RemoteEmbedder.decode` refuses its own provider
|
|
259
|
+
on the same rule; an app's own `Embedder` was the unchecked half.
|
|
252
260
|
- One `RemoteEmbedder` for every vendor: `baseUrl` selects the provider, the wire shape is the
|
|
253
261
|
same. Vectors are L2-normalised on arrival so `cosine` stays a dot product, and a width other
|
|
254
262
|
than the declared one is `X_VECTOR_DIM_MISMATCH` before anything reaches a store.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.3.1",
|
|
4
4
|
"description": "LLM gateway, versioned prompts, evals as tests, embeddings, hybrid vector search, RAG",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "19.
|
|
36
|
-
"@ultimat3/cache": "19.
|
|
37
|
-
"@ultimat3/core": "19.
|
|
38
|
-
"@ultimat3/db": "19.
|
|
39
|
-
"@ultimat3/jobs": "19.
|
|
40
|
-
"@ultimat3/money": "19.
|
|
41
|
-
"@ultimat3/policy": "19.
|
|
42
|
-
"@ultimat3/schema": "19.
|
|
43
|
-
"@ultimat3/time": "19.
|
|
35
|
+
"@ultimat3/action": "19.3.1",
|
|
36
|
+
"@ultimat3/cache": "19.3.1",
|
|
37
|
+
"@ultimat3/core": "19.3.1",
|
|
38
|
+
"@ultimat3/db": "19.3.1",
|
|
39
|
+
"@ultimat3/jobs": "19.3.1",
|
|
40
|
+
"@ultimat3/money": "19.3.1",
|
|
41
|
+
"@ultimat3/policy": "19.3.1",
|
|
42
|
+
"@ultimat3/schema": "19.3.1",
|
|
43
|
+
"@ultimat3/time": "19.3.1"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/embeddings.ts
CHANGED
|
@@ -40,7 +40,22 @@ export async function embedBatched(
|
|
|
40
40
|
finiteCount('embedBatched', 'size', size, 1);
|
|
41
41
|
const out: Float32Array[] = [];
|
|
42
42
|
for (let i = 0; i < texts.length; i += size) {
|
|
43
|
-
|
|
43
|
+
const batch = texts.slice(i, i + size);
|
|
44
|
+
const vectors = await embedder.embed(batch);
|
|
45
|
+
// "One vector per input text, in the order the texts arrived" is this interface's invariant,
|
|
46
|
+
// and it is the last place it can be checked: `indexDocument` writes `vectors[index]` per
|
|
47
|
+
// chunk, so a short answer stored `undefined` as a row and surfaced a layer later as a
|
|
48
|
+
// `TypeError` inside the vector store, naming nothing the app author wrote.
|
|
49
|
+
// `RemoteEmbedder.decode` already refuses its own provider on the same rule — an app's own
|
|
50
|
+
// `Embedder` was the unchecked half.
|
|
51
|
+
if (vectors.length !== batch.length) {
|
|
52
|
+
throw new AiEmbedderInvalidError({
|
|
53
|
+
embedder: embedder.name,
|
|
54
|
+
expected: batch.length,
|
|
55
|
+
received: vectors.length,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
out.push(...vectors);
|
|
44
59
|
}
|
|
45
60
|
return out;
|
|
46
61
|
}
|
package/src/errors.ts
CHANGED
|
@@ -374,20 +374,31 @@ export class EmbedderDimMismatchError extends UltimateError {
|
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
377
|
+
* An `Embedder` answered fewer vectors than it was given texts — a batch invariant the embedder
|
|
378
|
+
* itself broke, not a caller mistake. Distinct from `X_VECTOR_DIM_MISMATCH`: this fires before
|
|
379
|
+
* there is a vector at all, so there is nothing yet to measure the width of.
|
|
380
|
+
*
|
|
381
|
+
* The counts are optional because `embedOne` has nothing to report beyond "none for one", and its
|
|
382
|
+
* cause has always read that way; `embedBatched` knows both numbers and states them.
|
|
380
383
|
*/
|
|
381
384
|
export class AiEmbedderInvalidError extends UltimateError {
|
|
382
|
-
constructor(input: { embedder: string }) {
|
|
385
|
+
constructor(input: { embedder: string; expected?: number; received?: number }) {
|
|
386
|
+
const counted =
|
|
387
|
+
input.expected === undefined || input.received === undefined
|
|
388
|
+
? 'no vector for a batch of one text'
|
|
389
|
+
: `${input.received} vectors for a batch of ${input.expected} texts`;
|
|
383
390
|
super({
|
|
384
391
|
code: 'X_AI_EMBEDDER_INVALID',
|
|
385
|
-
cause: `embedder "${input.embedder}" returned
|
|
392
|
+
cause: `embedder "${input.embedder}" returned ${counted}`,
|
|
386
393
|
// The `${…}` the fix used to carry is unreadable to the `errors` gate, which blanks every
|
|
387
394
|
// interpolation — so the literal half alone has to name the call. Which embedder broke the
|
|
388
395
|
// invariant is a fact of the failure, and the cause and `meta` are where facts live.
|
|
389
396
|
fix: 'return one vector per input text from embed(), in the order the texts arrived',
|
|
390
|
-
meta: {
|
|
397
|
+
meta: {
|
|
398
|
+
embedder: input.embedder,
|
|
399
|
+
...(input.expected === undefined ? {} : { expected: input.expected }),
|
|
400
|
+
...(input.received === undefined ? {} : { received: input.received }),
|
|
401
|
+
},
|
|
391
402
|
});
|
|
392
403
|
}
|
|
393
404
|
}
|
package/src/pg-vector-sql.ts
CHANGED
|
@@ -197,6 +197,10 @@ export function deleteSql(
|
|
|
197
197
|
scope: VectorScope,
|
|
198
198
|
ids: readonly string[],
|
|
199
199
|
): SqlFragment {
|
|
200
|
+
// `in ()` is a syntax error, so an empty list takes the same constant an empty allow-list does.
|
|
201
|
+
// `PgVectorStore.delete` returns before it gets here, but this function is exported from
|
|
202
|
+
// `index.ts` — an app compiling the statement itself is a caller too.
|
|
203
|
+
const byId = ids.length === 0 ? NEVER : sql`"id" in (${join(ids.map((id) => sql`${id}`))})`;
|
|
200
204
|
return sql`delete from ${identifier(target.table)}
|
|
201
|
-
where
|
|
205
|
+
where ${byId} and ${conditionsSql(scope)}`;
|
|
202
206
|
}
|
package/src/rag.ts
CHANGED
|
@@ -168,6 +168,9 @@ export async function indexDocument(input: {
|
|
|
168
168
|
await input.store.upsert(
|
|
169
169
|
chunks.map((c, index) => ({
|
|
170
170
|
id: c.id,
|
|
171
|
+
// The cast is sound because `embedBatched` refuses an embedder that answered fewer vectors
|
|
172
|
+
// than it was given texts (`X_AI_EMBEDDER_INVALID`) — without that check this wrote
|
|
173
|
+
// `undefined` and failed a layer later inside the store, naming nothing an author wrote.
|
|
171
174
|
vector: vectors[index] as Float32Array,
|
|
172
175
|
text: c.text,
|
|
173
176
|
metadata: c.metadata,
|
package/src/scorers.ts
CHANGED
|
@@ -70,7 +70,12 @@ export function jsonSchemaValid(required: readonly string[]): Scorer {
|
|
|
70
70
|
}
|
|
71
71
|
if (typeof parsed !== 'object' || parsed === null) return 0;
|
|
72
72
|
const record = parsed as Record<string, unknown>;
|
|
73
|
-
|
|
73
|
+
// `Object.hasOwn`, never `record[key] !== undefined`: the keys are the CALLER's and the
|
|
74
|
+
// object is a `JSON.parse` result carrying `Object.prototype`, so the index read answered
|
|
75
|
+
// "present" for `constructor`, `toString`, `valueOf` and `hasOwnProperty` — a full 1 out of
|
|
76
|
+
// the scorer whose job is saying the answer holds none of them. It also read a declared
|
|
77
|
+
// `null` as absent, which is the same mistake pointing the other way.
|
|
78
|
+
const present = required.filter((key) => Object.hasOwn(record, key)).length;
|
|
74
79
|
return required.length === 0 ? 1 : present / required.length;
|
|
75
80
|
},
|
|
76
81
|
};
|
package/src/wire.ts
CHANGED
|
@@ -281,8 +281,14 @@ export class MessageStream {
|
|
|
281
281
|
|
|
282
282
|
private onMessageDelta(payload: Record<string, unknown>): readonly StreamChunk[] {
|
|
283
283
|
const delta = asRecord(payload['delta']);
|
|
284
|
-
|
|
285
|
-
|
|
284
|
+
// ABSENT is not a report, and neither is `null`. `!== null` alone admitted the absent key, so
|
|
285
|
+
// a `message_delta` carrying only running usage ran `parseStopReason(undefined)` — which
|
|
286
|
+
// answers `end_turn` — and rewrote a refusal already reported as a clean finish, erasing its
|
|
287
|
+
// `stopDetails` with it. `openai-wire.ts`'s `onFinish` returns early on `undefined` for the
|
|
288
|
+
// same reason; this was the copy that did not.
|
|
289
|
+
const reported = delta?.['stop_reason'];
|
|
290
|
+
if (delta !== undefined && reported !== null && reported !== undefined) {
|
|
291
|
+
this.stopReason = parseStopReason(reported);
|
|
286
292
|
// A refusal mid-stream keeps whatever was already streamed, so the reason alone reads as
|
|
287
293
|
// a complete answer that simply stopped. The detail is what says it is not one.
|
|
288
294
|
this.stopDetails = parseStopDetails(delta['stop_details']);
|