@ultimat3/ai 19.2.0 → 19.3.2

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 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.2.0",
3
+ "version": "19.3.2",
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.2.0",
36
- "@ultimat3/cache": "19.2.0",
37
- "@ultimat3/core": "19.2.0",
38
- "@ultimat3/db": "19.2.0",
39
- "@ultimat3/jobs": "19.2.0",
40
- "@ultimat3/money": "19.2.0",
41
- "@ultimat3/policy": "19.2.0",
42
- "@ultimat3/schema": "19.2.0",
43
- "@ultimat3/time": "19.2.0"
35
+ "@ultimat3/action": "19.3.2",
36
+ "@ultimat3/cache": "19.3.2",
37
+ "@ultimat3/core": "19.3.2",
38
+ "@ultimat3/db": "19.3.2",
39
+ "@ultimat3/jobs": "19.3.2",
40
+ "@ultimat3/money": "19.3.2",
41
+ "@ultimat3/policy": "19.3.2",
42
+ "@ultimat3/schema": "19.3.2",
43
+ "@ultimat3/time": "19.3.2"
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
- out.push(...(await embedder.embed(texts.slice(i, i + size))));
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
- * `embedOne` asked an `Embedder` for one vector and got none back — a batch-size invariant the
378
- * embedder itself broke, not a caller mistake. Distinct from `X_VECTOR_DIM_MISMATCH`: this fires
379
- * before there is a vector at all, so there is nothing yet to measure the width of.
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 no vector for a batch of one text`,
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: { embedder: input.embedder },
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
  }
@@ -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 "id" in (${join(ids.map((id) => sql`${id}`))}) and ${conditionsSql(scope)}`;
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
- const present = required.filter((key) => record[key] !== undefined).length;
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
- if (delta !== undefined && delta['stop_reason'] !== null) {
285
- this.stopReason = parseStopReason(delta['stop_reason']);
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']);