@ultimat3/ai 8.0.0 → 10.0.0
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 +26 -2
- package/README.md +8 -1
- package/package.json +10 -10
- package/src/agent-facts.ts +5 -0
- package/src/errors.ts +6 -21
- package/src/eval-errors.ts +6 -7
- package/src/evals.ts +4 -0
- package/src/gateway.ts +29 -4
- package/src/hive-errors.ts +6 -3
- package/src/models.ts +8 -1
- package/src/openai-provider.ts +6 -1
- package/src/prompt.ts +11 -1
- package/src/scorers.ts +20 -2
- package/src/sse.ts +7 -2
- package/src/vector-scope.ts +20 -5
- package/src/vector.ts +9 -1
package/CLAUDE.md
CHANGED
|
@@ -54,7 +54,7 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
54
54
|
| `llm-stream.ts` | `.stream()`'s plumbing: the sink, the ambient mark, the one-turn drive |
|
|
55
55
|
| `agent.ts` | `agent()` — the tool loop, declared as an `action` |
|
|
56
56
|
| `agent-transcript.ts` | what one turn leaves in the transcript: the assistant replay, the tool results, the correction |
|
|
57
|
-
| `agent-facts.ts` | `describeAgents()` — the agent registry and the row a manifest
|
|
57
|
+
| `agent-facts.ts` | `describeAgents()` — the agent registry, and the row a manifest WOULD publish: nothing reads it yet, because `manifest` is tier 4 like this package and the consumer has to be `cli` at tier 5 |
|
|
58
58
|
| `agent-job.ts` | `agentJob()` — an agent as a real `JobHandle`, composed from `job()` |
|
|
59
59
|
| `hive.ts` | `hive()` — one action fanned out over many inputs, declared as an `action` |
|
|
60
60
|
| `hive-result.ts` | `HiveMember` / `HiveResult`, and the SCHEMA built from the member's own `output` |
|
|
@@ -120,6 +120,25 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
120
120
|
zero leaks the ceiling upward on every release.
|
|
121
121
|
- Cost is `Money` (integer minor units), rounded **up**. Never a float, never a division
|
|
122
122
|
that loses a fraction.
|
|
123
|
+
- **`attempt` collects TRANSPORT failures only, `As of 2026-08-23`.** A caught value that is an
|
|
124
|
+
`UltimateError` whose code is not `X_AI_PROVIDER_UNAVAILABLE` is rethrown verbatim, on the spot:
|
|
125
|
+
those are raised locally, before the socket opens — a missing credential, a control the model does
|
|
126
|
+
not accept — so the same rejection is waiting on every provider and every attempt, which is
|
|
127
|
+
exactly the reason a 400 is not retried. Collecting one flattened `X_AI_KEY_MISSING` and its
|
|
128
|
+
runnable `export ANTHROPIC_API_KEY=…` into "no provider could serve model X", and made
|
|
129
|
+
`generate()` and `stream()` answer the SAME misconfiguration two different ways — `stream()`
|
|
130
|
+
never routes through `attempt`. `AiTransportError` carries `X_AI_PROVIDER_UNAVAILABLE`, so
|
|
131
|
+
"provider one 503'd, provider two timed out" still collects across candidates unchanged.
|
|
132
|
+
**Breaking**: a caller catching `X_AI_PROVIDER_UNAVAILABLE` today starts seeing the real code.
|
|
133
|
+
The read is `stringField(error, 'code')`, never `error.code` — the value came from an app's
|
|
134
|
+
`Provider` and a property read on it is a getter call or a `Proxy` trap.
|
|
135
|
+
- **`Gateway.stream` routes BEFORE it reserves.** `providerFor` throws for a registered model no
|
|
136
|
+
configured provider serves, and it used to run between `reserve()` and the `try` that releases —
|
|
137
|
+
so the estimate was debited, landed on the `BudgetStore` under `actorKey`/`orgKey`, and nothing
|
|
138
|
+
credited it back. `MemoryBudgetStore` is per process and never expires, so five refused streams
|
|
139
|
+
spent an org's whole ceiling with nothing ever sent, and every later call in that process was
|
|
140
|
+
`X_AI_BUDGET_EXCEEDED` forever. `settled = true` moved AFTER `await ledger.record(...)` for the
|
|
141
|
+
same reason: a store that throws at `done` left the reservation both unreleased and half-recorded.
|
|
123
142
|
- **The gateway's two reads of a provider's throw are total.** A `Provider` is the APP's object, so
|
|
124
143
|
the value it rejects with is one the framework did not build: `isRetryable` indexes it (a getter,
|
|
125
144
|
or a `Proxy` trap) and fails closed if the read raises, and the failure line goes through core's
|
|
@@ -424,7 +443,12 @@ until 2026-08, naming a tool no catalog contained (`llm.test.ts`, `agent.test.ts
|
|
|
424
443
|
- An aborted `ctx` unwinds the whole hive with `X_ABORTED`, which is a DIFFERENT event from
|
|
425
444
|
`onMemberError: 'abort'`: the latter is a completed run with a partial harvest worth returning,
|
|
426
445
|
the former has nobody left to hand it to.
|
|
427
|
-
- **`describeAgents()`
|
|
446
|
+
- **`describeAgents()` is OFFERED, not published, `As of 2026-08-23`** — no reader exists anywhere
|
|
447
|
+
in the tree, and neither does one for `registeredModels()`. Both are tier-4 exports whose only
|
|
448
|
+
legal consumer is `@ultimat3/cli` (tier 5); `@ultimat3/manifest` is tier 4, so the obvious import
|
|
449
|
+
is a sideways edge `bun run boundaries` refuses. Their doc comments say so now, because a comment
|
|
450
|
+
claiming a consumer that does not exist is the documentation axiom 3 calls no rule at all.
|
|
451
|
+
- **`describeAgents()` describes what an `ActionDescriptor` cannot.** An agent projects to the same
|
|
428
452
|
descriptor as any other action, and that descriptor knows nothing about turns, tools, models or
|
|
429
453
|
prompt hashes — so "how far can this loop and what may it call" had no answer outside the source.
|
|
430
454
|
Same shape as `describePrompts()` / `describeEvals()`, and deliberately NOT a new
|
package/README.md
CHANGED
|
@@ -79,6 +79,7 @@ default store at `replicas: 6` is six ledgers of twenty million, which is a budg
|
|
|
79
79
|
| A control nobody asked for is **omitted**, never defaulted | a default sent as a request is indistinguishable on the wire from one that was declared |
|
|
80
80
|
| A refusal is `X_LLM_REFUSED`, not a schema failure | it is a 200 with no answer in it, and a repair turn buys the same refusal again |
|
|
81
81
|
| The refusal's `alternative` is only ever a **more capable** model | registration order is most-capable-first and `moreCapableThan` walks it upward; retrying a refusal on a weaker model is the one retry that cannot help, so an unbeatable model gets no suggestion at all |
|
|
82
|
+
| A local refusal is never collected into `X_AI_PROVIDER_UNAVAILABLE` | `X_AI_KEY_MISSING` and `X_AI_REQUEST_INVALID` are raised before the request leaves; retrying them across providers burns attempts on the same answer and discards the runnable `fix:`. `generate()` and `stream()` therefore answer the same misconfiguration the same way |
|
|
82
83
|
| Fallback is across **providers serving one model**, never across models | a silent model swap changes what answered, what it cost and which eval baseline the answer belongs to; the gateway stamps `result.provider`, and `llm()` puts it on the span as `llm.provider`, so the fallback that does exist is never silent |
|
|
83
84
|
| The repair turn replays the tool call's arguments, never an empty `text` | an answer through the `respond` tool leaves `text` empty, and an empty text block is a 400 — the repair came back as `X_AI_PROVIDER_UNAVAILABLE` |
|
|
84
85
|
| `reserve()` **debits** the estimate and takes a turn | three concurrent calls otherwise read the same `spent()`, all pass, and all three record against a ceiling only one of them fitted; `record` reconciles and `release` gives it back |
|
|
@@ -451,6 +452,12 @@ describeAgents();
|
|
|
451
452
|
// mcp: true }]
|
|
452
453
|
```
|
|
453
454
|
|
|
455
|
+
**Offered, not yet published, `As of 2026-08-23`.** Nothing in the framework reads it: `describeAgents()`
|
|
456
|
+
lives at tier 4, `@ultimat3/manifest` is tier 4 too, and a sideways import is a build error — so the
|
|
457
|
+
consumer has to be `@ultimat3/cli` at tier 5, and that wiring has not landed. Call it yourself and
|
|
458
|
+
the rows are real; wait for `x manifest` to carry them and you will wait. Same for
|
|
459
|
+
`registeredModels()`.
|
|
460
|
+
|
|
454
461
|
An agent projects to an `ActionDescriptor` like any other action, and that descriptor knows nothing
|
|
455
462
|
about turns or tools — so "how far can this loop, and what may it call" had no answer outside the
|
|
456
463
|
source. Names are read when you ask, not when the agent was declared: `registerAction` stamps them
|
|
@@ -625,7 +632,7 @@ actor comes from the request context, never from the model.
|
|
|
625
632
|
|
|
626
633
|
| Code | Meaning |
|
|
627
634
|
|---|---|
|
|
628
|
-
| `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model
|
|
635
|
+
| `X_AI_PROVIDER_UNAVAILABLE` | every provider for the model was unreachable; lists what each said. A TRANSPORT failure only — a coded refusal raised before the socket opens (`X_AI_KEY_MISSING`, `X_AI_REQUEST_INVALID`) reaches the caller as itself, `As of 2026-08-23`, because the same rejection waits on every provider and every attempt and its `fix:` is the whole point of it |
|
|
629
636
|
| `X_AI_BUDGET_EXCEEDED` | refused pre-flight, naming the scope and what remains |
|
|
630
637
|
| `X_AI_GATEWAY_MISSING` | an `llm()` action ran before `configureAi` |
|
|
631
638
|
| `X_AI_PROMPT_VERSION` | version drift, or a render missing a declared variable |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ai",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "10.0.0",
|
|
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": "
|
|
36
|
-
"@ultimat3/cache": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/jobs": "
|
|
40
|
-
"@ultimat3/money": "
|
|
41
|
-
"@ultimat3/policy": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
43
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/action": "10.0.0",
|
|
36
|
+
"@ultimat3/cache": "10.0.0",
|
|
37
|
+
"@ultimat3/core": "10.0.0",
|
|
38
|
+
"@ultimat3/db": "10.0.0",
|
|
39
|
+
"@ultimat3/jobs": "10.0.0",
|
|
40
|
+
"@ultimat3/money": "10.0.0",
|
|
41
|
+
"@ultimat3/policy": "10.0.0",
|
|
42
|
+
"@ultimat3/schema": "10.0.0",
|
|
43
|
+
"@ultimat3/time": "10.0.0"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/src/agent-facts.ts
CHANGED
|
@@ -51,6 +51,11 @@ export function registerAgentFact(target: AnyAction, facts: () => Omit<AgentFact
|
|
|
51
51
|
/**
|
|
52
52
|
* Every registered agent, by name.
|
|
53
53
|
*
|
|
54
|
+
* OFFERED, not yet published. Nothing outside this package reads it: `@ultimat3/manifest` is tier 4
|
|
55
|
+
* and so is this package, so a direct import is a sideways edge the boundary check refuses, and the
|
|
56
|
+
* consumer has to be `@ultimat3/cli` at tier 5. Until that wiring lands, "the row a manifest
|
|
57
|
+
* publishes" describes the SHAPE, never a row any manifest carries.
|
|
58
|
+
*
|
|
54
59
|
* An agent still carrying no name is left out, and that is not a silent drop: a name is stamped by
|
|
55
60
|
* `registerAction`, an action without one reaches no route, no tool catalogue and no queue, so
|
|
56
61
|
* there is no capability for a row to describe. `named()` builds a TWIN rather than naming in
|
package/src/errors.ts
CHANGED
|
@@ -65,7 +65,12 @@ registerErrorCodes(
|
|
|
65
65
|
Object.fromEntries(Object.entries(AI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
66
66
|
);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
69
|
+
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
70
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
|
|
71
|
+
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
|
|
72
|
+
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
73
|
+
// would be the same constant in eight places waiting to drift again.
|
|
69
74
|
|
|
70
75
|
/** Every configured provider refused or errored. Carries what each one said. */
|
|
71
76
|
export class AiProviderUnavailableError extends UltimateError {
|
|
@@ -74,7 +79,6 @@ export class AiProviderUnavailableError extends UltimateError {
|
|
|
74
79
|
code: 'X_AI_PROVIDER_UNAVAILABLE',
|
|
75
80
|
cause: `no provider could serve model "${input.model}" (${input.attempts.join(' | ')})`,
|
|
76
81
|
fix: 'check ai.providers in app.config.ts and the provider API key env var',
|
|
77
|
-
docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -99,7 +103,6 @@ export class AiBudgetExceededError extends UltimateError {
|
|
|
99
103
|
`request needs ${input.requested} ${input.unit ?? 'tokens'} but scope ` +
|
|
100
104
|
`"${input.scope}" has ${input.remaining} of ${input.limit} left`,
|
|
101
105
|
fix: `raise ai.budget for "${input.scope}" in app.config.ts, or shorten the prompt`,
|
|
102
|
-
docs: docsFor('X_AI_BUDGET_EXCEEDED'),
|
|
103
106
|
});
|
|
104
107
|
}
|
|
105
108
|
}
|
|
@@ -115,7 +118,6 @@ export class AiGatewayMissingError extends UltimateError {
|
|
|
115
118
|
code: 'X_AI_GATEWAY_MISSING',
|
|
116
119
|
cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured`,
|
|
117
120
|
fix: 'configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) }) at boot',
|
|
118
|
-
docs: docsFor('X_AI_GATEWAY_MISSING'),
|
|
119
121
|
});
|
|
120
122
|
}
|
|
121
123
|
}
|
|
@@ -137,7 +139,6 @@ export class AiModelUnknownError extends UltimateError {
|
|
|
137
139
|
// The `errors` gate blanks every interpolation, so the literal half alone has to name the
|
|
138
140
|
// call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
|
|
139
141
|
fix: 'registerModel({ id, contextWindow, maxOutput, inputPerMillion, outputPerMillion, cacheMinimumTokens, reasoning }) at boot, before configureAi',
|
|
140
|
-
docs: docsFor('X_AI_MODEL_UNKNOWN'),
|
|
141
142
|
meta: { model: input.model },
|
|
142
143
|
});
|
|
143
144
|
}
|
|
@@ -155,7 +156,6 @@ export class AiPromptSecretError extends UltimateError {
|
|
|
155
156
|
code: 'X_AI_PROMPT_SECRET',
|
|
156
157
|
cause: `prompt "${input.ref}" was given a Secret in vars(): ${input.keys.join(', ')}`,
|
|
157
158
|
fix: 'drop the key from vars() and from the template, or revealSecret(value) in vars() if the model genuinely has to read it',
|
|
158
|
-
docs: docsFor('X_AI_PROMPT_SECRET'),
|
|
159
159
|
});
|
|
160
160
|
}
|
|
161
161
|
}
|
|
@@ -172,7 +172,6 @@ export class LlmStreamInvalidError extends UltimateError {
|
|
|
172
172
|
code: 'X_LLM_STREAM_INVALID',
|
|
173
173
|
cause: `streamed answer to prompt "${input.prompt}" failed its output schema: ${input.issues}`,
|
|
174
174
|
fix: 'call the action instead of .stream() when the answer must satisfy a structured schema — a stream has already delivered its tokens and cannot take a repair turn',
|
|
175
|
-
docs: docsFor('X_LLM_STREAM_INVALID'),
|
|
176
175
|
});
|
|
177
176
|
}
|
|
178
177
|
}
|
|
@@ -190,7 +189,6 @@ export class LlmOutputInvalidError extends UltimateError {
|
|
|
190
189
|
`prompt "${input.prompt}" returned output failing its schema on all ` +
|
|
191
190
|
`${input.attempts} attempts: ${input.issues}`,
|
|
192
191
|
fix: 'describe the output shape in the prompt template and bump its version, or widen `output` in the llm() declaration',
|
|
193
|
-
docs: docsFor('X_LLM_OUTPUT_INVALID'),
|
|
194
192
|
});
|
|
195
193
|
}
|
|
196
194
|
}
|
|
@@ -213,7 +211,6 @@ export class AgentMaxTurnsError extends UltimateError {
|
|
|
213
211
|
`agent "${input.agent}" used all ${input.turns} turns and ${input.calls} tool calls ` +
|
|
214
212
|
`without calling the respond tool`,
|
|
215
213
|
fix: 'tell the template when to stop and answer through the respond tool, then bump its version — raise maxTurns only once the run demonstrably converges',
|
|
216
|
-
docs: docsFor('X_AGENT_MAX_TURNS'),
|
|
217
214
|
meta: { agent: input.agent, turns: input.turns },
|
|
218
215
|
});
|
|
219
216
|
}
|
|
@@ -234,7 +231,6 @@ export class AgentToolUnexposedError extends UltimateError {
|
|
|
234
231
|
code: 'X_AGENT_TOOL_UNEXPOSED',
|
|
235
232
|
cause: `agent "${input.agent}" lists tools no MCP surface exposes: ${input.tools.join(', ')}`,
|
|
236
233
|
fix: 'add mcp: { expose: true } to the action named in cause, or drop it from the agent tools list',
|
|
237
|
-
docs: docsFor('X_AGENT_TOOL_UNEXPOSED'),
|
|
238
234
|
});
|
|
239
235
|
}
|
|
240
236
|
}
|
|
@@ -270,7 +266,6 @@ export class LlmRefusedError extends UltimateError {
|
|
|
270
266
|
input.alternative === undefined
|
|
271
267
|
? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
|
|
272
268
|
: `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
|
|
273
|
-
docs: docsFor('X_LLM_REFUSED'),
|
|
274
269
|
meta: { model: input.model, category: input.category },
|
|
275
270
|
});
|
|
276
271
|
}
|
|
@@ -287,7 +282,6 @@ export class LlmTruncatedError extends UltimateError {
|
|
|
287
282
|
code: 'X_LLM_TRUNCATED',
|
|
288
283
|
cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
|
|
289
284
|
fix: `set maxTokens: ${input.maxTokens * 2} on the llm() declaration, or drop fields from its output schema`,
|
|
290
|
-
docs: docsFor('X_LLM_TRUNCATED'),
|
|
291
285
|
});
|
|
292
286
|
}
|
|
293
287
|
}
|
|
@@ -301,7 +295,6 @@ export class AiPromptVersionError extends UltimateError {
|
|
|
301
295
|
input.available.length > 0 ? input.available.join(', ') : 'none'
|
|
302
296
|
})`,
|
|
303
297
|
fix: 'bump the version in definePrompt after editing the template, then x manifest',
|
|
304
|
-
docs: docsFor('X_AI_PROMPT_VERSION'),
|
|
305
298
|
});
|
|
306
299
|
}
|
|
307
300
|
}
|
|
@@ -317,7 +310,6 @@ export class AiPromptRenderError extends UltimateError {
|
|
|
317
310
|
code: 'X_AI_PROMPT_VERSION',
|
|
318
311
|
cause: `prompt "${input.ref}" was rendered without: ${input.missing.join(', ')}`,
|
|
319
312
|
fix: 'pass every {{variable}} the template declares, or remove it from the template',
|
|
320
|
-
docs: docsFor('X_AI_PROMPT_VERSION'),
|
|
321
313
|
});
|
|
322
314
|
}
|
|
323
315
|
}
|
|
@@ -331,7 +323,6 @@ export class VectorDimMismatchError extends UltimateError {
|
|
|
331
323
|
// Not `x ai reindex`: that command is PLANNED and throws, so a fix line naming it sends an
|
|
332
324
|
// operator to a wall. A fix has to be performable today, which here means app code.
|
|
333
325
|
fix: 'use the same embedder that created the store, or re-embed every record at the new width and upsert it',
|
|
334
|
-
docs: docsFor('X_VECTOR_DIM_MISMATCH'),
|
|
335
326
|
});
|
|
336
327
|
}
|
|
337
328
|
}
|
|
@@ -349,7 +340,6 @@ export class VectorScopeWidenedError extends UltimateError {
|
|
|
349
340
|
`store "${input.store}" is bound to tenant "${input.held}" and cannot be re-scoped ` +
|
|
350
341
|
`to "${input.requested}"`,
|
|
351
342
|
fix: `derive from the unscoped store instead: vectorStore.scoped({ tenant: '${input.requested}' })`,
|
|
352
|
-
docs: docsFor('X_VECTOR_SCOPE_WIDENED'),
|
|
353
343
|
});
|
|
354
344
|
}
|
|
355
345
|
}
|
|
@@ -367,7 +357,6 @@ export class EmbedderDimMismatchError extends UltimateError {
|
|
|
367
357
|
`embedder "${input.embedder}" is declared with ${input.expected} dimensions but the ` +
|
|
368
358
|
`provider returned ${input.received}`,
|
|
369
359
|
fix: `set dimension: ${input.received} on the embedder, then re-embed every record at that width and upsert it`,
|
|
370
|
-
docs: docsFor('X_VECTOR_DIM_MISMATCH'),
|
|
371
360
|
});
|
|
372
361
|
}
|
|
373
362
|
}
|
|
@@ -386,7 +375,6 @@ export class AiEmbedderInvalidError extends UltimateError {
|
|
|
386
375
|
// interpolation — so the literal half alone has to name the call. Which embedder broke the
|
|
387
376
|
// invariant is a fact of the failure, and the cause and `meta` are where facts live.
|
|
388
377
|
fix: 'return one vector per input text from embed(), in the order the texts arrived',
|
|
389
|
-
docs: docsFor('X_AI_EMBEDDER_INVALID'),
|
|
390
378
|
meta: { embedder: input.embedder },
|
|
391
379
|
});
|
|
392
380
|
}
|
|
@@ -399,7 +387,6 @@ export class AiKeyMissingError extends UltimateError {
|
|
|
399
387
|
code: 'X_AI_KEY_MISSING',
|
|
400
388
|
cause: `provider "${input.provider}" has no API key: ${input.envVar} is unset and none was passed to its constructor`,
|
|
401
389
|
fix: `export ${input.envVar}=<key>, or pass { apiKey } when constructing the provider`,
|
|
402
|
-
docs: docsFor('X_AI_KEY_MISSING'),
|
|
403
390
|
meta: { provider: input.provider, envVar: input.envVar },
|
|
404
391
|
});
|
|
405
392
|
}
|
|
@@ -416,7 +403,6 @@ export class AiRequestInvalidError extends UltimateError {
|
|
|
416
403
|
code: 'X_AI_REQUEST_INVALID',
|
|
417
404
|
cause: input.detail,
|
|
418
405
|
fix: input.fix,
|
|
419
|
-
docs: docsFor('X_AI_REQUEST_INVALID'),
|
|
420
406
|
});
|
|
421
407
|
}
|
|
422
408
|
}
|
|
@@ -446,7 +432,6 @@ export class AiTransportError extends UltimateError {
|
|
|
446
432
|
input.status === undefined ? 'failed' : `returned ${input.status}`
|
|
447
433
|
}: ${input.detail}`,
|
|
448
434
|
fix: fixForStatus(input.status, input.envVar),
|
|
449
|
-
docs: docsFor('X_AI_PROVIDER_UNAVAILABLE'),
|
|
450
435
|
meta: { provider: input.provider, status: input.status },
|
|
451
436
|
});
|
|
452
437
|
this.status = input.status;
|
package/src/eval-errors.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// stay in ./errors — one owner, one registration, one place a duplicate can surface.
|
|
4
4
|
|
|
5
5
|
import { UltimateError } from '@ultimat3/core';
|
|
6
|
-
import type { AiErrorCode } from './errors';
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
8
|
+
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
9
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
|
|
10
|
+
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
|
|
11
|
+
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
12
|
+
// would be the same constant in eight places waiting to drift again.
|
|
9
13
|
|
|
10
14
|
/**
|
|
11
15
|
* An eval scored further below its recorded baseline than its tolerance allows. The gate is the
|
|
@@ -33,7 +37,6 @@ export class EvalThresholdError extends UltimateError {
|
|
|
33
37
|
// so the eval's own name there is `X_CLI_BAD_FLAG` ("not a test type") — a fix line that
|
|
34
38
|
// cannot be run is axiom 4 broken at the one moment it is needed.
|
|
35
39
|
fix: `x test eval --filter ${input.eval} to see per-case scores, then fix the prompt — or ULTIMATE_EVAL_RECORD=1 x test eval to accept the new numbers as a reviewed diff`,
|
|
36
|
-
docs: docsFor('X_EVAL_THRESHOLD'),
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
42
|
}
|
|
@@ -48,7 +51,6 @@ export class EvalBaselineMissingError extends UltimateError {
|
|
|
48
51
|
code: 'X_EVAL_BASELINE_MISSING',
|
|
49
52
|
cause: `eval "${input.eval}" gates against ${input.path}, which ${input.reason}`,
|
|
50
53
|
fix: input.fix ?? `ULTIMATE_EVAL_RECORD=1 x test eval, then commit ${input.path}`,
|
|
51
|
-
docs: docsFor('X_EVAL_BASELINE_MISSING'),
|
|
52
54
|
});
|
|
53
55
|
}
|
|
54
56
|
}
|
|
@@ -60,7 +62,6 @@ export class EvalBaselineInvalidError extends UltimateError {
|
|
|
60
62
|
code: 'X_EVAL_BASELINE_INVALID',
|
|
61
63
|
cause: `the recorded baseline ${input.path} ${input.problem}`,
|
|
62
64
|
fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
|
|
63
|
-
docs: docsFor('X_EVAL_BASELINE_INVALID'),
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -75,7 +76,6 @@ export class EvalMissingError extends UltimateError {
|
|
|
75
76
|
code: 'X_EVAL_MISSING',
|
|
76
77
|
cause: `prompt "${input.prompt}" has no eval`,
|
|
77
78
|
fix: `defineEval({ name: '${input.id}', prompt, cases, scorers, tolerance, baseline }) beside the prompt, then ULTIMATE_EVAL_RECORD=1 x test eval`,
|
|
78
|
-
docs: docsFor('X_EVAL_MISSING'),
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
}
|
|
@@ -92,7 +92,6 @@ export class EvalRecordingError extends UltimateError {
|
|
|
92
92
|
code: 'X_EVAL_RECORDING',
|
|
93
93
|
cause: `${input.env} is set, so every eval would re-record its baseline instead of gating on it`,
|
|
94
94
|
fix: `env -u ${input.env} x verify`,
|
|
95
|
-
docs: docsFor('X_EVAL_RECORDING'),
|
|
96
95
|
});
|
|
97
96
|
}
|
|
98
97
|
}
|
package/src/evals.ts
CHANGED
|
@@ -183,6 +183,10 @@ async function runEval<V extends PromptVars>(
|
|
|
183
183
|
...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
|
|
184
184
|
...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
|
|
185
185
|
...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
|
|
186
|
+
// `thinking` too, and not because it is tidy: `contentHash` covers it, so the baseline is
|
|
187
|
+
// filed under a hash describing a configuration the measurement did not use. `llm()` and
|
|
188
|
+
// `agent()` have always sent both.
|
|
189
|
+
...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
|
|
186
190
|
});
|
|
187
191
|
const perScorer: Record<string, number> = {};
|
|
188
192
|
for (const scorer of input.scorers) {
|
package/src/gateway.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// budgeted, and cost-accounted in integer minor units. Everything an app does with a model
|
|
5
5
|
// goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
|
|
6
6
|
|
|
7
|
-
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { isUltimateError, renderThrowable, stringField } from '@ultimat3/core';
|
|
8
8
|
import type { Money } from '@ultimat3/money';
|
|
9
9
|
import type { BudgetLimits, BudgetStore } from './budget';
|
|
10
10
|
import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
|
|
@@ -32,6 +32,9 @@ export interface RetryPolicy {
|
|
|
32
32
|
|
|
33
33
|
export const DEFAULT_RETRY: RetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 };
|
|
34
34
|
|
|
35
|
+
/** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
|
|
36
|
+
const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
|
|
37
|
+
|
|
35
38
|
export interface CreateGatewayInput {
|
|
36
39
|
/** Tried in order for a given model. First provider that lists the model wins. */
|
|
37
40
|
readonly providers: readonly Provider[];
|
|
@@ -124,9 +127,13 @@ class GatewayImpl implements Gateway {
|
|
|
124
127
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
125
128
|
const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
|
|
126
129
|
const resolved: GenerateRequest = { ...request, model };
|
|
127
|
-
const ledger = currentBudget();
|
|
128
|
-
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
129
130
|
|
|
131
|
+
// Routed BEFORE the reservation, not after it. `providerFor` throws for a registered model no
|
|
132
|
+
// configured provider serves — an ordinary boot misconfiguration — and a debit taken first has
|
|
133
|
+
// nothing to credit it back: the throw is outside the `finally` below, and the estimate has
|
|
134
|
+
// already landed on the `BudgetStore`, which is per process and never expires. Five refused
|
|
135
|
+
// streams and the org's ceiling is gone for the life of the process, with nothing ever sent.
|
|
136
|
+
//
|
|
130
137
|
// The streaming path does not retry AT ALL — not mid-flight, and not on the handshake either.
|
|
131
138
|
// Mid-flight is the obvious one: the consumer has already been handed tokens, and replaying
|
|
132
139
|
// from the top would duplicate them. The handshake is not separable from it here, because
|
|
@@ -136,6 +143,9 @@ class GatewayImpl implements Gateway {
|
|
|
136
143
|
// its fallback across providers belong to `generate` alone. A caller that wants either uses
|
|
137
144
|
// `generate`, or reconnects itself and knows what it has already shown.
|
|
138
145
|
const provider = this.providerFor(model);
|
|
146
|
+
|
|
147
|
+
const ledger = currentBudget();
|
|
148
|
+
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
139
149
|
let settled = false;
|
|
140
150
|
try {
|
|
141
151
|
for await (const chunk of provider.stream(resolved)) {
|
|
@@ -143,8 +153,10 @@ class GatewayImpl implements Gateway {
|
|
|
143
153
|
yield chunk;
|
|
144
154
|
continue;
|
|
145
155
|
}
|
|
146
|
-
|
|
156
|
+
// Settled only once `record` has LANDED. Marking it first left a store that threw here
|
|
157
|
+
// holding the reservation and half the record — the `finally` saw a settled stream.
|
|
147
158
|
await ledger?.record(chunk.result.usage, chunk.result.cost, reservation);
|
|
159
|
+
settled = true;
|
|
148
160
|
yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
|
|
149
161
|
}
|
|
150
162
|
} finally {
|
|
@@ -190,6 +202,19 @@ class GatewayImpl implements Gateway {
|
|
|
190
202
|
try {
|
|
191
203
|
return { ...(await call(provider)), provider: provider.name };
|
|
192
204
|
} catch (error) {
|
|
205
|
+
// A coded refusal that is NOT `X_AI_PROVIDER_UNAVAILABLE` reaches the caller verbatim.
|
|
206
|
+
// Those are raised locally, before the socket opens — a missing credential, a control
|
|
207
|
+
// the model does not accept — so the same rejection is waiting on every provider and
|
|
208
|
+
// every attempt, which is the reason a 400 is not retried three lines below. Collecting
|
|
209
|
+
// one into `X_AI_PROVIDER_UNAVAILABLE` discards its runnable `fix:` and answers the same
|
|
210
|
+
// failure a different way from `stream`, which does not route through here at all.
|
|
211
|
+
// A transport failure keeps the old path: `AiTransportError` IS
|
|
212
|
+
// `X_AI_PROVIDER_UNAVAILABLE`, so "provider one 503'd, provider two timed out" still
|
|
213
|
+
// collects across the candidates. `stringField` rather than `error.code`, because the
|
|
214
|
+
// value came from an app's `Provider` and a property read on it can trap.
|
|
215
|
+
if (isUltimateError(error) && stringField(error, 'code') !== PROVIDER_UNAVAILABLE) {
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
193
218
|
// `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
|
|
194
219
|
// `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
|
|
195
220
|
// refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
|
package/src/hive-errors.ts
CHANGED
|
@@ -4,9 +4,13 @@
|
|
|
4
4
|
// duplicate can surface.
|
|
5
5
|
|
|
6
6
|
import { UltimateError } from '@ultimat3/core';
|
|
7
|
-
import type { AiErrorCode } from './errors';
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
// No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
|
|
9
|
+
// which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
10
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
|
|
11
|
+
// which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
|
|
12
|
+
// answered 404, host included, on every error it has ever thrown; restating the replacement here
|
|
13
|
+
// would be the same constant in eight places waiting to drift again.
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* `split` handed back an empty list, so the hive fanned out to nobody and would have reported a
|
|
@@ -23,7 +27,6 @@ export class HiveEmptyError extends UltimateError {
|
|
|
23
27
|
code: 'X_HIVE_EMPTY',
|
|
24
28
|
cause: `the hive over "${input.member}" split into 0 members, so no member ran`,
|
|
25
29
|
fix: `return at least one member input from the hive's split() over "${input.member}", or skip the hive call when the source is empty — a hive reporting 0 ok and 0 failed cannot be told apart from one whose query returned no rows`,
|
|
26
|
-
docs: docsFor('X_HIVE_EMPTY'),
|
|
27
30
|
meta: { member: input.member },
|
|
28
31
|
});
|
|
29
32
|
}
|
package/src/models.ts
CHANGED
|
@@ -128,7 +128,14 @@ export function modelIds(): readonly ModelId[] {
|
|
|
128
128
|
return [...registry.keys()];
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Every registered spec, in ladder order.
|
|
133
|
+
*
|
|
134
|
+
* OFFERED, not yet published: nothing in the tree reads it. `@ultimat3/manifest` is tier 4 and so
|
|
135
|
+
* is this package, so the consumer has to be `@ultimat3/cli` (tier 5) — a direct import would be a
|
|
136
|
+
* sideways edge the boundary check refuses. The doc claimed `x manifest` consumed it, which is
|
|
137
|
+
* exactly the kind of statement axiom 3 says is not a rule.
|
|
138
|
+
*/
|
|
132
139
|
export function registeredModels(): readonly ModelSpec[] {
|
|
133
140
|
return [...registry.values()];
|
|
134
141
|
}
|
package/src/openai-provider.ts
CHANGED
|
@@ -113,8 +113,13 @@ class OpenAiProvider implements Provider {
|
|
|
113
113
|
* open socket past the HTTP timeout and fails after the completion was generated and billed.
|
|
114
114
|
*/
|
|
115
115
|
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
116
|
-
|
|
116
|
+
// Resolved BEFORE the transport question is asked. `requiresStreaming` clamps `maxTokens` to
|
|
117
|
+
// the model's `maxOutput`, and its own fallback for an absent model is the framework's
|
|
118
|
+
// `DEFAULT_MODEL` — a Claude id no OpenAI-format endpoint serves. Sized against that, a
|
|
119
|
+
// request naming no model was measured against somebody else's ceiling, seventy lines above
|
|
120
|
+
// `modelOf`'s comment saying this provider never does that.
|
|
117
121
|
const model = this.modelOf(request);
|
|
122
|
+
if (requiresStreaming({ ...request, model })) return this.assemble(request);
|
|
118
123
|
const response = await this.send(
|
|
119
124
|
chatCompletionBody({ request, model, stream: false }),
|
|
120
125
|
false,
|
package/src/prompt.ts
CHANGED
|
@@ -27,7 +27,17 @@ export interface DefinePromptInput<V extends PromptVars> {
|
|
|
27
27
|
readonly system?: string;
|
|
28
28
|
/** Schema of the variables, for the manifest. */
|
|
29
29
|
readonly input?: JsonSchema;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* The output shape this prompt PROMISES. Declarative: it is hashed into `ref` and published by
|
|
32
|
+
* `describePrompts()`, and it is deliberately never sent on the wire — structured output is the
|
|
33
|
+
* `respond` tool `llm()` projects from the ACTION's `output`, and a second path through
|
|
34
|
+
* `output_config.format` / `response_format` is the ambiguity axiom 1 refuses (it is also the
|
|
35
|
+
* one feature most OpenAI-compatible servers do not implement).
|
|
36
|
+
*
|
|
37
|
+
* Editing it moves the hash and so needs a `version` bump, which is the point: a prompt whose
|
|
38
|
+
* promised shape changed is a different prompt, and every score already filed against the old
|
|
39
|
+
* hash describes the old one.
|
|
40
|
+
*/
|
|
31
41
|
readonly output?: JsonSchema;
|
|
32
42
|
readonly model?: ModelId;
|
|
33
43
|
readonly effort?: Effort;
|
package/src/scorers.ts
CHANGED
|
@@ -13,8 +13,22 @@ export interface Scorer {
|
|
|
13
13
|
score(input: { output: string; expected?: string }): Promise<number> | number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Scores outside 0..1 are a scorer bug; clamping keeps one from skewing a whole run's mean.
|
|
18
|
+
*
|
|
19
|
+
* `NaN` is the case a pair of comparisons cannot express, and it is the dangerous one: both
|
|
20
|
+
* `n < 0` and `n > 1` are false for it, so it propagated through `mean()` into `EvalResult.score`,
|
|
21
|
+
* and `regressionsAgainst`'s `now < was - tolerance` is false for `NaN` too — the run reported a
|
|
22
|
+
* pass over a number that measured nothing. The trigger is ordinary arithmetic in an app's own
|
|
23
|
+
* scorer: `output.length / expected.length` on a case that declares no `expected`.
|
|
24
|
+
*
|
|
25
|
+
* Zero rather than a throw, and rather than a new code: zero is a REGRESSION against any recorded
|
|
26
|
+
* baseline, so the scorer bug surfaces through `X_EVAL_THRESHOLD`, which already names the case
|
|
27
|
+
* that produced it. It also makes recording and gating agree — `ULTIMATE_EVAL_RECORD=1` wrote
|
|
28
|
+
* `"score": null` for a `NaN`, which `parseBaseline` then refuses as `X_EVAL_BASELINE_INVALID`.
|
|
29
|
+
*/
|
|
30
|
+
export const clampScore = (n: number): number =>
|
|
31
|
+
Number.isFinite(n) ? (n < 0 ? 0 : n > 1 ? 1 : n) : n > 0 ? 1 : 0;
|
|
18
32
|
|
|
19
33
|
/** Exact match after trimming. The strictest and cheapest scorer; prefer it when it fits. */
|
|
20
34
|
export const exact: Scorer = {
|
|
@@ -98,6 +112,10 @@ export function llmJudge(input: {
|
|
|
98
112
|
maxTokens: input.maxTokens ?? 256,
|
|
99
113
|
...(input.judge.system !== undefined ? { system: input.judge.system } : {}),
|
|
100
114
|
...(input.judge.model !== undefined ? { model: input.judge.model } : {}),
|
|
115
|
+
// The judge prompt's hash is this scorer's NAME, and `contentHash` covers `effort` and
|
|
116
|
+
// `thinking` — so dropping either measures with a judge the name does not describe.
|
|
117
|
+
...(input.judge.effort !== undefined ? { effort: input.judge.effort } : {}),
|
|
118
|
+
...(input.judge.thinking !== undefined ? { thinking: input.judge.thinking } : {}),
|
|
101
119
|
});
|
|
102
120
|
// The judge is asked for a bare 0..1; anything else scores 0 rather than guessing.
|
|
103
121
|
const parsed = Number.parseFloat(generated.text.trim());
|
package/src/sse.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
import { AiTransportError } from './errors';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* The most one unterminated frame may buffer
|
|
11
|
+
* The most one unterminated frame may buffer — the REMAINDER after every complete frame has been
|
|
12
|
+
* split off, never the whole decode buffer. A peer that sends a body with no frame boundary in
|
|
12
13
|
* it — an HTML error page, a proxy answering on the model's port, a hung gateway — grows `buffer`
|
|
13
14
|
* without limit, and no read deadline interrupts it because every individual read SUCCEEDS. Coded
|
|
14
15
|
* failure > OOM, the same call `@ultimat3/mail`'s `createReplyParser` makes for the same shape.
|
|
@@ -86,10 +87,14 @@ export async function* readSse(
|
|
|
86
87
|
const { done, value } = await reader.read();
|
|
87
88
|
if (done) break;
|
|
88
89
|
buffer += decoder.decode(value, { stream: true });
|
|
89
|
-
guard(buffer, provider);
|
|
90
90
|
const decoded = decodeSse(buffer);
|
|
91
91
|
buffer = decoded.rest;
|
|
92
92
|
for (const frame of decoded.frames) yield frame;
|
|
93
|
+
// The REMAINDER, after the split, and after the frames that did complete were delivered.
|
|
94
|
+
// Measured against the whole decode buffer it refused a busy provider that landed a
|
|
95
|
+
// megabyte of perfectly framed deltas in one read — every one of them terminated — for
|
|
96
|
+
// "sending more than 1048576 characters without completing one SSE frame".
|
|
97
|
+
guard(buffer, provider);
|
|
93
98
|
}
|
|
94
99
|
buffer += decoder.decode();
|
|
95
100
|
const tail = frameOf(buffer);
|
package/src/vector-scope.ts
CHANGED
|
@@ -49,17 +49,28 @@ function narrowTenant(
|
|
|
49
49
|
throw new VectorScopeWidenedError({ store, held: base, requested: next });
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* A `Map`, never a `Record`, because every key here is a CALLER's string — an app's metadata field
|
|
54
|
+
* name, chosen by whoever wrote the policy. An object gets it wrong in both directions:
|
|
55
|
+
* - reading `merged['constructor']` answers the `Object` function off the prototype chain, so
|
|
56
|
+
* `held === undefined` is false and `held.includes(value)` is a bare `TypeError` raised inside
|
|
57
|
+
* a path whose only contract is `X_VECTOR_SCOPE_WIDENED`;
|
|
58
|
+
* - writing `merged['__proto__'] = [...]` runs `Object.prototype`'s setter rather than creating
|
|
59
|
+
* the key, so the rule the caller declared is absent from `Object.entries` and the derived
|
|
60
|
+
* scope comes out WIDER than the one that was asked for.
|
|
61
|
+
* `Object.fromEntries` defines own properties, so the object handed back has neither hazard.
|
|
62
|
+
*/
|
|
52
63
|
function narrowAllow(
|
|
53
64
|
base: Readonly<Record<string, readonly string[]>> | undefined,
|
|
54
65
|
next: Readonly<Record<string, readonly string[]>> | undefined,
|
|
55
66
|
): Readonly<Record<string, readonly string[]>> | undefined {
|
|
56
67
|
if (next === undefined) return base;
|
|
57
|
-
const merged
|
|
68
|
+
const merged = new Map<string, readonly string[]>(Object.entries(base ?? {}));
|
|
58
69
|
for (const [key, values] of Object.entries(next)) {
|
|
59
|
-
const held = merged
|
|
60
|
-
merged
|
|
70
|
+
const held = merged.get(key);
|
|
71
|
+
merged.set(key, held === undefined ? values : values.filter((value) => held.includes(value)));
|
|
61
72
|
}
|
|
62
|
-
return merged;
|
|
73
|
+
return Object.fromEntries(merged);
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/** Whether one stored row survives the scope. The in-memory twin of the SQL conditions. */
|
|
@@ -70,7 +81,11 @@ export function scopeAdmits(
|
|
|
70
81
|
): boolean {
|
|
71
82
|
if (scope.tenant !== undefined && scope.tenant !== tenant) return false;
|
|
72
83
|
return Object.entries(scope.allow ?? {}).every(([key, values]) => {
|
|
73
|
-
|
|
84
|
+
// Own properties only, for the reason `narrowAllow` uses a `Map`: `key` is a caller's string
|
|
85
|
+
// and a metadata bag is a caller's object. This half already failed CLOSED — an inherited
|
|
86
|
+
// member is never one of the allowed strings — but relying on that is relying on the value
|
|
87
|
+
// types, not on the rule, and the rule is that a caller's string is never an object key.
|
|
88
|
+
const value = Object.hasOwn(metadata, key) ? metadata[key] : undefined;
|
|
74
89
|
return value !== undefined && values.includes(value);
|
|
75
90
|
});
|
|
76
91
|
}
|
package/src/vector.ts
CHANGED
|
@@ -218,4 +218,12 @@ export function fuse(rankings: readonly (readonly SearchHit[])[], rrfK = 60): re
|
|
|
218
218
|
.sort(byScoreDesc);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Score descending, then id ascending — the second key is `pg-vector-sql.ts`'s
|
|
223
|
+
* `order by f.score desc, d."id" asc`, and the two have to agree or the developer machine and the
|
|
224
|
+
* deployed app return different pages of the same search. Ties are the COMMON case in RRF: two
|
|
225
|
+
* documents that swap rank between the dense and the lexical list score identically, and a stable
|
|
226
|
+
* sort then resolves them by dense-list insertion order, which no SQL engine reproduces.
|
|
227
|
+
*/
|
|
228
|
+
const byScoreDesc = (a: SearchHit, b: SearchHit): number =>
|
|
229
|
+
b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|